From 32cd9fadfb9d50d38abd2198a2645d5503ce046f Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 29 Nov 2024 10:25:47 -0600 Subject: [PATCH 01/64] Windows flit note --- docs/dev.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev.md b/docs/dev.md index a816ca9..1c313f6 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -16,6 +16,7 @@ Then install dependencies: pip install flit flit install --symlink ``` +For Windows, use `--pth-file` instead of `--symlink`. Then run tests to confirm that it works: ``` From 448467246e07fb71902aa9ebb61417f04195a368 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 29 Nov 2024 10:26:24 -0600 Subject: [PATCH 02/64] Add anywidget to dev extra --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3c7cbd3..3ebede2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ test = [ "pytest-xdist", ] dev = [ + "anywidget[dev]", "ipython", "jupyterlab", ] From 450069a5531c673e83c8c56af0bc63eab6bddeb4 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 29 Nov 2024 10:39:51 -0600 Subject: [PATCH 03/64] Initial AnyWidget setup --- docs/dev.md | 12 +++++++++ examples/widget.ipynb | 52 +++++++++++++++++++++++++++++++++++++++ pyabc2/widget/__init__.py | 14 +++++++++++ pyabc2/widget/index.css | 3 +++ pyabc2/widget/index.js | 8 ++++++ 5 files changed, 89 insertions(+) create mode 100644 examples/widget.ipynb create mode 100644 pyabc2/widget/__init__.py create mode 100644 pyabc2/widget/index.css create mode 100644 pyabc2/widget/index.js diff --git a/docs/dev.md b/docs/dev.md index 1c313f6..e1d509c 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -30,3 +30,15 @@ pre-commit install ``` You can now make a branch on your fork, work on the code, push your branch to GitHub, and make a PR to the parent repo. + +## Widget + +Enable hot reloading by setting environment variable `ANYWIDGET_HMR` to `1` +before starting Jupyter Lab. + +```bash +export ANYWIDGET_HMR=1 +``` +```powershell +$env:ANYWIDGET_HMR = "1" +``` diff --git a/examples/widget.ipynb b/examples/widget.ipynb new file mode 100644 index 0000000..7d41886 --- /dev/null +++ b/examples/widget.ipynb @@ -0,0 +1,52 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "from pyabc2.widget import ABCJSWidget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "ABCJSWidget()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py new file mode 100644 index 0000000..d2da309 --- /dev/null +++ b/pyabc2/widget/__init__.py @@ -0,0 +1,14 @@ +""" +abcjs widget for Jupyter and more. +""" + +from pathlib import Path + +import anywidget + +HERE = Path(__file__).parent + + +class ABCJSWidget(anywidget.AnyWidget): + _esm = HERE / "index.js" + _css = HERE / "index.css" diff --git a/pyabc2/widget/index.css b/pyabc2/widget/index.css new file mode 100644 index 0000000..c8b68d0 --- /dev/null +++ b/pyabc2/widget/index.css @@ -0,0 +1,3 @@ +#abcjs-container { + color: forestgreen; +} diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js new file mode 100644 index 0000000..c252d91 --- /dev/null +++ b/pyabc2/widget/index.js @@ -0,0 +1,8 @@ +function render({ model, el }) { + let div = document.createElement('div'); + div.innerHTML = `ABC is cool`; + div.id = 'abcjs-container'; + el.appendChild(div); +} + +export default { render }; From 935d00ba7ef6790ed89029caa67eb07b619846ca Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 29 Nov 2024 11:42:01 -0600 Subject: [PATCH 04/64] Initial rendered ABC display - seems like abcjs is not ES6 so can't `import` - loading from CDN happens on widget creation, but not update - but currently creation controls where it shows up (the first #music, though update creates additional elements) --- examples/widget.ipynb | 17 ++++++++++++++++- pyabc2/__init__.py | 4 +--- pyabc2/widget/__init__.py | 2 ++ pyabc2/widget/index.css | 7 ++++++- pyabc2/widget/index.js | 34 ++++++++++++++++++++++++++++++++-- 5 files changed, 57 insertions(+), 7 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index 7d41886..4cb85f5 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -17,7 +17,8 @@ "metadata": {}, "outputs": [], "source": [ - "ABCJSWidget()" + "w = ABCJSWidget()\n", + "w" ] }, { @@ -26,6 +27,20 @@ "id": "2", "metadata": {}, "outputs": [], + "source": [ + "w.abc = \"\"\"\\\n", + "K: G\n", + "G,A,B,C DEFG | ABcd efga | bc'd'\n", + "\"\"\"\n", + "w" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], "source": [] } ], diff --git a/pyabc2/__init__.py b/pyabc2/__init__.py index 6987834..414d8b7 100644 --- a/pyabc2/__init__.py +++ b/pyabc2/__init__.py @@ -5,7 +5,7 @@ __version__ = "0.1.0.dev2" from .note import Key, Note -from .parse import Tune, _load_abcjs_if_in_jupyter +from .parse import Tune from .pitch import Pitch, PitchClass __all__ = ( @@ -15,5 +15,3 @@ "PitchClass", "Tune", ) - -_load_abcjs_if_in_jupyter() diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index d2da309..3a62f6a 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -5,6 +5,7 @@ from pathlib import Path import anywidget +import traitlets HERE = Path(__file__).parent @@ -12,3 +13,4 @@ class ABCJSWidget(anywidget.AnyWidget): _esm = HERE / "index.js" _css = HERE / "index.css" + abc = traitlets.Unicode("").tag(sync=True) diff --git a/pyabc2/widget/index.css b/pyabc2/widget/index.css index c8b68d0..c1a548a 100644 --- a/pyabc2/widget/index.css +++ b/pyabc2/widget/index.css @@ -1,3 +1,8 @@ -#abcjs-container { +#container { color: forestgreen; + border: 1px solid forestgreen; +} + +#music { + border: 1px dashed grey; } diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index c252d91..edaeda6 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -1,8 +1,38 @@ +const ABCJS_URL = 'https://cdn.jsdelivr.net/npm/abcjs@6.4.4/dist/abcjs-basic-min.js'; + + +function initialize({ model }) { + // TODO: skip if already loaded? + return new Promise((resolve, reject) => { + let script = document.createElement('script'); + script.src = ABCJS_URL; + script.onload = () => { + console.log('ABCJS loaded'); + resolve(); + }; + script.onerror = () => { + console.error('Failed to load ABCJS'); + reject(); + }; + document.head.appendChild(script); + }); +} + + function render({ model, el }) { + let abc = () => model.get('abc'); + let div = document.createElement('div'); div.innerHTML = `ABC is cool`; - div.id = 'abcjs-container'; + div.id = 'container'; el.appendChild(div); + + // ABCJS render target + let music = document.createElement('div'); + music.id = 'music'; + div.appendChild(music); + + ABCJS.renderAbc('music', abc()); } -export default { render }; +export default { initialize, render }; From e5d09d8e5e2a28ba6ff7c493be1ae081147f6eb2 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 29 Nov 2024 16:20:06 -0600 Subject: [PATCH 05/64] Update on change --- examples/widget.ipynb | 112 ++++++++++++++++++++++++++++++++++++++-- pyabc2/widget/index.css | 4 +- pyabc2/widget/index.js | 70 ++++++++++++++++++++++--- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index 4cb85f5..cb60bef 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -7,6 +7,8 @@ "metadata": {}, "outputs": [], "source": [ + "import ipywidgets as ipw\n", + "\n", "from pyabc2.widget import ABCJSWidget" ] }, @@ -28,9 +30,20 @@ "metadata": {}, "outputs": [], "source": [ + "w.abc = \"ABCD\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "w = ABCJSWidget()\n", "w.abc = \"\"\"\\\n", "K: G\n", - "G,A,B,C DEFG | ABcd efga | bc'd'\n", + "G,A,B,C DEFG | ABcd efga | bc'd'e' f'g'a'\n", "\"\"\"\n", "w" ] @@ -38,10 +51,103 @@ { "cell_type": "code", "execution_count": null, - "id": "3", + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "w" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "w" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "w.abc += \"c'\"\n", + "w" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "w" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## Compare to ipywidgets behavior" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "slider = ipw.FloatSlider(min=200, max=500, value=400)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "slider" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "slider" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "slider" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "# We can do this and all display isntances change and the slider still shows for this cell\n", + "slider.value += 1\n", + "slider" + ] } ], "metadata": { diff --git a/pyabc2/widget/index.css b/pyabc2/widget/index.css index c1a548a..b66f387 100644 --- a/pyabc2/widget/index.css +++ b/pyabc2/widget/index.css @@ -1,8 +1,8 @@ -#container { +.container { color: forestgreen; border: 1px solid forestgreen; } -#music { +.music { border: 1px dashed grey; } diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index edaeda6..b8f664d 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -3,6 +3,9 @@ const ABCJS_URL = 'https://cdn.jsdelivr.net/npm/abcjs@6.4.4/dist/abcjs-basic-min function initialize({ model }) { // TODO: skip if already loaded? + + model.set("_active_music_ids", []); + return new Promise((resolve, reject) => { let script = document.createElement('script'); script.src = ABCJS_URL; @@ -19,20 +22,73 @@ function initialize({ model }) { } +function getRandomString() { + // from a-z and 0-1 + return Math.random().toString(36).substring(2, 9); +} + + function render({ model, el }) { + console.log("render") + let abc = () => model.get('abc'); + let active_music_ids = model.get("_active_music_ids"); + + let container = el; + container.classList.add('container'); - let div = document.createElement('div'); - div.innerHTML = `ABC is cool`; - div.id = 'container'; - el.appendChild(div); + let head = document.createElement('div'); // ABCJS render target let music = document.createElement('div'); - music.id = 'music'; - div.appendChild(music); + let music_id = 'music' + '-' + getRandomString(); + music.id = music_id; + music.classList.add('music'); + active_music_ids.push(music_id); + + container.appendChild(head); + container.appendChild(music); + + function on_change() { + console.log(`render_abc ${music_id}`); + + // Is music empty? + if (music.innerHTML !== '') { + console.log(`music ${music_id} is not empty`); + music.innerHTML = ''; + }; + + head.innerHTML = `abcjs widget
${abc()}`; + + // NOTE: doesn't work with `music_id` passed as target, + // even though it should, still not sure why + let tunes = ABCJS.renderAbc(music, abc()); + if (tunes.length === 0) { + console.log(`no tunes rendered for ${music_id}`); + }; + } + + // Initial render + on_change(); + + // Listen for changes + model.on("change:abc", on_change); + + // Clean up + return () => { + console.log(`cleanup ${music_id}`); + + container.innerHTML = ''; + + // Remove ID from active music IDs + let i = active_music_ids.indexOf(music_id); + if (i > -1) { + active_music_ids.splice(i, 1); + }; - ABCJS.renderAbc('music', abc()); + // Remove callback + model.off("change:abc", on_change); + }; } export default { initialize, render }; From 55626ed93fa859c84a93bfa1d4f108005db962d9 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 29 Nov 2024 16:40:55 -0600 Subject: [PATCH 06/64] Some abcjs renderAbc option sliders --- examples/widget.ipynb | 35 +++++++++++++++++++++++++++++++++++ pyabc2/widget/__init__.py | 4 ++++ pyabc2/widget/index.js | 17 +++++++++++++++-- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index cb60bef..a381cb9 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -148,6 +148,41 @@ "slider.value += 1\n", "slider" ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## Compose with slider" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "w = ABCJSWidget()\n", + "w.abc = \" CDEF GABc |\" * 4\n", + "\n", + "width_slider = ipw.IntSlider(min=300, max=1000, value=740, description=\"Staff width (px?)\")\n", + "scale_slider = ipw.FloatSlider(min=0.2, max=3, value=1, description=\"Scaling factor\")\n", + "\n", + "ipw.link((w, \"staff_width\"), (width_slider, \"value\"))\n", + "ipw.link((w, \"scale\"), (scale_slider, \"value\"))\n", + "\n", + "ipw.VBox([width_slider, scale_slider, w])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index 3a62f6a..d05d614 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -13,4 +13,8 @@ class ABCJSWidget(anywidget.AnyWidget): _esm = HERE / "index.js" _css = HERE / "index.css" + abc = traitlets.Unicode("").tag(sync=True) + + staff_width = traitlets.Integer(740).tag(sync=True) + scale = traitlets.Float(1.0).tag(sync=True) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index b8f664d..e0510b3 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -32,6 +32,10 @@ function render({ model, el }) { console.log("render") let abc = () => model.get('abc'); + + let staffWidth = () => model.get('staff_width'); + let scale = () => model.get('scale'); + let active_music_ids = model.get("_active_music_ids"); let container = el; @@ -62,7 +66,14 @@ function render({ model, el }) { // NOTE: doesn't work with `music_id` passed as target, // even though it should, still not sure why - let tunes = ABCJS.renderAbc(music, abc()); + let tunes = ABCJS.renderAbc( + music, + abc(), + { + staffwidth: staffWidth(), + scale: scale(), + }, + ); if (tunes.length === 0) { console.log(`no tunes rendered for ${music_id}`); }; @@ -72,7 +83,9 @@ function render({ model, el }) { on_change(); // Listen for changes - model.on("change:abc", on_change); + // model.on("change:abc", on_change); + // model.on("change:scale", on_change); + model.on("change", on_change); // any change? // Clean up return () => { From ff105284abe9d67718488463b4b6e907cc477f39 Mon Sep 17 00:00:00 2001 From: zmoon Date: Sun, 1 Dec 2024 09:55:48 -0600 Subject: [PATCH 07/64] Params can be set in init without needing to override __init__ --- examples/widget.ipynb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index a381cb9..3e410c5 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -176,13 +176,23 @@ "ipw.VBox([width_slider, scale_slider, w])" ] }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## Set params in init" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "ABCJSWidget(abc=\"K:G\\n\" + \" G,A,B,C DEFG | \" * 4, staff_width=200)" + ] } ], "metadata": { From e3539329a2930d8356d4d6a5fbe58f010eaf78e2 Mon Sep 17 00:00:00 2001 From: zmoon Date: Tue, 10 Dec 2024 17:48:31 -0600 Subject: [PATCH 08/64] todo --- pyabc2/widget/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index e0510b3..4141f68 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -4,6 +4,9 @@ const ABCJS_URL = 'https://cdn.jsdelivr.net/npm/abcjs@6.4.4/dist/abcjs-basic-min function initialize({ model }) { // TODO: skip if already loaded? + // TODO: display logo (on first load) + // https://raw.githubusercontent.com/paulrosen/abcjs/refs/heads/main/docs/.vuepress/public/img/abcjs_comp_extended_08.svg + model.set("_active_music_ids", []); return new Promise((resolve, reject) => { From b009f3c53f7c13a258af550b63515b4d75181b31 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 20 Mar 2025 11:42:28 -0500 Subject: [PATCH 09/64] Only load once and show logo then seems like restarting notebook kernel won't get you to the first load, have to refresh the web page and it would be nice to display it at the same hierarchy level as the widget container, instead of within it could also add an option to include it within the widget when requested --- examples/widget.ipynb | 3 ++- pyabc2/widget/index.js | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index 3e410c5..e8e82ff 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -210,7 +210,8 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" + "pygments_lexer": "ipython3", + "version": "3.12.9" } }, "nbformat": 4, diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index 4141f68..d5601a0 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -1,15 +1,22 @@ const ABCJS_URL = 'https://cdn.jsdelivr.net/npm/abcjs@6.4.4/dist/abcjs-basic-min.js'; +const ABCJS_LOGO_URL = 'https://raw.githubusercontent.com/paulrosen/abcjs/' + + 'refs/heads/main/docs/.vuepress/public/img/abcjs_comp_extended_08.svg'; function initialize({ model }) { - // TODO: skip if already loaded? - - // TODO: display logo (on first load) - // https://raw.githubusercontent.com/paulrosen/abcjs/refs/heads/main/docs/.vuepress/public/img/abcjs_comp_extended_08.svg model.set("_active_music_ids", []); + model.set("_first_load", null); return new Promise((resolve, reject) => { + if (window.ABCJS) { + model.set("_first_load", false); + console.log('ABCJS already loaded'); + resolve(); + return; + }; + + model.set("_first_load", true); let script = document.createElement('script'); script.src = ABCJS_URL; script.onload = () => { @@ -40,10 +47,19 @@ function render({ model, el }) { let scale = () => model.get('scale'); let active_music_ids = model.get("_active_music_ids"); + let first_load = model.get("_first_load"); + console.log(`first_load ${first_load}`); let container = el; container.classList.add('container'); + if (first_load) { + let logo = document.createElement('img'); + logo.src = ABCJS_LOGO_URL; + logo.height = '24'; + container.appendChild(logo); + } + let head = document.createElement('div'); // ABCJS render target From 4e2d335ccd568c0706f987023c8f0552f35374fe Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 20 Mar 2025 12:52:31 -0500 Subject: [PATCH 10/64] Expose the SVGs tune.engraver.svgs also them, but not sure if the same thing or not --- examples/widget.ipynb | 18 +++++++++++++++++- pyabc2/widget/__init__.py | 5 +++++ pyabc2/widget/index.js | 11 +++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index e8e82ff..d39962d 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -8,6 +8,7 @@ "outputs": [], "source": [ "import ipywidgets as ipw\n", + "from IPython.display import SVG\n", "\n", "from pyabc2.widget import ABCJSWidget" ] @@ -48,6 +49,21 @@ "w" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "81be4139-ca8a-4c99-b2c5-9615e4eaacab", + "metadata": {}, + "outputs": [], + "source": [ + "# NOTE: empty the first time this cell is called if using restart and run to selected cell\n", + "print(len(w.svgs), \"SVGs\")\n", + "for i, s in enumerate(w.svgs):\n", + " display(SVG(s))\n", + " with open(f\"music{i}.svg\", \"w\") as f:\n", + " f.write(s)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -144,7 +160,7 @@ "metadata": {}, "outputs": [], "source": [ - "# We can do this and all display isntances change and the slider still shows for this cell\n", + "# We can do this and all display instances change and the slider still shows for this cell\n", "slider.value += 1\n", "slider" ] diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index d05d614..35d59fd 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -14,7 +14,12 @@ class ABCJSWidget(anywidget.AnyWidget): _esm = HERE / "index.js" _css = HERE / "index.css" + # Input abc = traitlets.Unicode("").tag(sync=True) + # Output + svgs = traitlets.List(traitlets.Unicode, []).tag(sync=True) + + # Options staff_width = traitlets.Integer(740).tag(sync=True) scale = traitlets.Float(1.0).tag(sync=True) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index d5601a0..7c1fefa 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -96,6 +96,17 @@ function render({ model, el }) { if (tunes.length === 0) { console.log(`no tunes rendered for ${music_id}`); }; + + // Get the SVGs that have been rendered + console.log(`music ${music_id} has ${music.children.length} children`); + let svg_arr = []; + for (let child of music.children) { + if (child.tagName.toLowerCase() !== 'svg') {continue}; + let svg_str = new XMLSerializer().serializeToString(child); + svg_arr.push(svg_str); + } + model.set('svgs', svg_arr); + model.save_changes(); } // Initial render From a0e647d1a7462a0b02b8cee9810252efb23e0138 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 20 Mar 2025 17:00:29 -0500 Subject: [PATCH 11/64] Expose more display options --- examples/widget.ipynb | 103 +++++++++++++++++++++++++++----------- pyabc2/widget/__init__.py | 12 ++++- pyabc2/widget/index.css | 8 ++- pyabc2/widget/index.js | 42 +++++++++++++--- 4 files changed, 126 insertions(+), 39 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index d39962d..c3c714d 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -20,7 +20,7 @@ "metadata": {}, "outputs": [], "source": [ - "w = ABCJSWidget()\n", + "w = ABCJSWidget(logo=True)\n", "w" ] }, @@ -46,38 +46,34 @@ "K: G\n", "G,A,B,C DEFG | ABcd efga | bc'd'e' f'g'a'\n", "\"\"\"\n", - "w" + "display(w)" ] }, { "cell_type": "code", "execution_count": null, - "id": "81be4139-ca8a-4c99-b2c5-9615e4eaacab", + "id": "4", "metadata": {}, "outputs": [], "source": [ - "# NOTE: empty the first time this cell is called if using restart and run to selected cell\n", - "print(len(w.svgs), \"SVGs\")\n", - "for i, s in enumerate(w.svgs):\n", - " display(SVG(s))\n", - " with open(f\"music{i}.svg\", \"w\") as f:\n", - " f.write(s)" + "w" ] }, { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "6", "metadata": {}, "outputs": [], "source": [ + "w.abc += \"c'\" # All display instances are modified!\n", "w" ] }, { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -87,22 +83,32 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "be272066-b594-4a8c-ac2d-2e3961aa0e7f", "metadata": {}, "outputs": [], "source": [ - "w.abc += \"c'\"\n", - "w" + "w = ABCJSWidget(hide=True)\n", + "w.abc = \"\"\"\\\n", + "K: G\n", + "G,A,B,C DEFG | ABcd efga | bc'd'e' f'g'a'\n", + "\"\"\"\n", + "display(w)\n", + "print(w.svgs) # NOTE: Empty at this point" ] }, { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "81be4139-ca8a-4c99-b2c5-9615e4eaacab", "metadata": {}, "outputs": [], "source": [ - "w" + "# NOTE: empty the first time this cell is called if using restart and run to selected cell\n", + "print(len(w.svgs), \"SVG(s)\")\n", + "for i, s in enumerate(w.svgs):\n", + " display(SVG(s))\n", + " with open(f\"music{i}.svg\", \"w\") as f:\n", + " f.write(s)" ] }, { @@ -143,16 +149,6 @@ "slider" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "slider" - ] - }, { "cell_type": "code", "execution_count": null, @@ -170,7 +166,7 @@ "id": "14", "metadata": {}, "source": [ - "## Compose with slider" + "## Compose with ipywidgets" ] }, { @@ -180,16 +176,63 @@ "metadata": {}, "outputs": [], "source": [ - "w = ABCJSWidget()\n", - "w.abc = \" CDEF GABc |\" * 4\n", + "abc = \"K:C\\nT:The best scale\\n\" + \"CDEF GABc | \" * 4\n", + "w = ABCJSWidget(abc=abc, foreground=\"#303030\")\n", + "\n", + "input_box = ipw.Textarea(value=None, placeholder=\"Type something\", layout={\"width\": \"500px\", \"height\": \"5rem\"})\n", "\n", "width_slider = ipw.IntSlider(min=300, max=1000, value=740, description=\"Staff width (px?)\")\n", + "line_thickness_slider = ipw.FloatSlider(min=-0.4, max=2, step=0.05, value=0, description=\"Line thickness increase factor\")\n", "scale_slider = ipw.FloatSlider(min=0.2, max=3, value=1, description=\"Scaling factor\")\n", + "transpose_slider = ipw.IntSlider(min=-12, max=12, value=0, description=\"Transpose (half steps)\")\n", + "foreground_picker = ipw.ColorPicker(\n", + " concise=False,\n", + " description=\"Foreground color\",\n", + " value=w.foreground,\n", + ")\n", + "\n", + "save_button = ipw.Button(description=\"Save\", tooltip=\"Save to SVG\")\n", + "\n", + "# logo_cbox = ipw.Checkbox(description=\"Add logo\", indent=False) # currently have to decide at init\n", + "debug_box_cbox = ipw.Checkbox(description=\"Box\", indent=False)\n", + "debug_grid_cbox = ipw.Checkbox(description=\"Grid\", indent=False)\n", + "debug_input_cbox = ipw.Checkbox(description=\"Input\", indent=False)\n", "\n", + "ipw.link((w, \"abc\"), (input_box, \"value\"))\n", "ipw.link((w, \"staff_width\"), (width_slider, \"value\"))\n", "ipw.link((w, \"scale\"), (scale_slider, \"value\"))\n", + "ipw.link((w, \"line_thickness_increase\"), (line_thickness_slider, \"value\"))\n", + "ipw.link((w, \"transpose\"), (transpose_slider, \"value\"))\n", + "ipw.link((w, \"foreground\"), (foreground_picker, \"value\"))\n", + "# ipw.link((w, \"logo\"), (logo_cbox, \"value\"))\n", + "ipw.link((w, \"debug_box\"), (debug_box_cbox, \"value\"))\n", + "ipw.link((w, \"debug_grid\"), (debug_grid_cbox, \"value\"))\n", + "ipw.link((w, \"debug_input\"), (debug_input_cbox, \"value\"))\n", + "\n", + "def save(btn):\n", + " for i, s in enumerate(w.svgs):\n", + " with open(f\"music{i}.svg\", \"w\") as f:\n", + " f.write(s)\n", + "\n", + "save_button.on_click(save)\n", "\n", - "ipw.VBox([width_slider, scale_slider, w])" + "ipw.VBox([\n", + " input_box,\n", + " width_slider,\n", + " scale_slider,\n", + " line_thickness_slider,\n", + " transpose_slider,\n", + " foreground_picker,\n", + " # logo_cbox,\n", + " w,\n", + " save_button,\n", + " ipw.HBox([\n", + " ipw.Label(value=\"Debug options:\", layout={\"width\": \"100px\"}),\n", + " debug_box_cbox,\n", + " debug_grid_cbox,\n", + " debug_input_cbox,\n", + " ]),\n", + "])" ] }, { diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index 35d59fd..a93e1cb 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -11,6 +11,8 @@ class ABCJSWidget(anywidget.AnyWidget): + """Display SVG sheet music rendered from ABC notation by abcjs.""" + _esm = HERE / "index.js" _css = HERE / "index.css" @@ -21,5 +23,13 @@ class ABCJSWidget(anywidget.AnyWidget): svgs = traitlets.List(traitlets.Unicode, []).tag(sync=True) # Options - staff_width = traitlets.Integer(740).tag(sync=True) + debug_box = traitlets.Bool(False).tag(sync=True) + debug_grid = traitlets.Bool(False).tag(sync=True) + debug_input = traitlets.Bool(False).tag(sync=True) + foreground = traitlets.Unicode(None, allow_none=True).tag(sync=True) + hide = traitlets.Bool(False).tag(sync=True) + line_thickness_increase = traitlets.Float(0.0).tag(sync=True) + logo = traitlets.Bool(False).tag(sync=True) scale = traitlets.Float(1.0).tag(sync=True) + staff_width = traitlets.Integer(740).tag(sync=True) + transpose = traitlets.Integer(0).tag(sync=True) diff --git a/pyabc2/widget/index.css b/pyabc2/widget/index.css index b66f387..5a5f1bd 100644 --- a/pyabc2/widget/index.css +++ b/pyabc2/widget/index.css @@ -1,8 +1,12 @@ -.container { +div.container.debug { color: forestgreen; border: 1px solid forestgreen; } -.music { +div.music.debug { border: 1px dashed grey; } + +div.container code { + white-space: pre-wrap; +} diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index 7c1fefa..4197d3f 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -43,8 +43,16 @@ function render({ model, el }) { let abc = () => model.get('abc'); - let staffWidth = () => model.get('staff_width'); + let foregroundColor = () => model.get('foreground'); + let hide = () => model.get('hide'); + let lineThickness = () => model.get('line_thickness_increase'); let scale = () => model.get('scale'); + let showDebugBox = () => model.get('debug_box'); + let showDebugGrid = () => model.get('debug_grid'); + let showDebugInput = () => model.get('debug_input'); + let showLogo = () => model.get('logo'); + let staffWidth = () => model.get('staff_width'); + let visualTranspose = () => model.get('transpose'); let active_music_ids = model.get("_active_music_ids"); let first_load = model.get("_first_load"); @@ -53,7 +61,7 @@ function render({ model, el }) { let container = el; container.classList.add('container'); - if (first_load) { + if ((first_load || showLogo()) && !hide()) { let logo = document.createElement('img'); logo.src = ABCJS_LOGO_URL; logo.height = '24'; @@ -69,8 +77,10 @@ function render({ model, el }) { music.classList.add('music'); active_music_ids.push(music_id); - container.appendChild(head); - container.appendChild(music); + if (!hide()) { + container.appendChild(head); + container.appendChild(music); + } function on_change() { console.log(`render_abc ${music_id}`); @@ -81,7 +91,23 @@ function render({ model, el }) { music.innerHTML = ''; }; - head.innerHTML = `abcjs widget
${abc()}`; + if (showDebugInput() && !hide()) { + head.innerHTML = `${abc()}`; + } else { + head.innerHTML = ''; + } + if (showDebugBox() && !hide()) { + music.classList.add('debug'); + container.classList.add('debug'); + } else { + music.classList.remove('debug'); + container.classList.remove('debug'); + } + + // Visual debug settings + let showDebug = []; + if (showDebugBox()) {showDebug.push('box')}; + if (showDebugGrid()) {showDebug.push('grid')}; // NOTE: doesn't work with `music_id` passed as target, // even though it should, still not sure why @@ -89,8 +115,12 @@ function render({ model, el }) { music, abc(), { - staffwidth: staffWidth(), + foregroundColor: foregroundColor(), + lineThickness: lineThickness(), scale: scale(), + showDebug: showDebug, + staffwidth: staffWidth(), + visualTranspose: visualTranspose(), }, ); if (tunes.length === 0) { From f8c8ac308d92db29779e7245cc896d34ccca1842 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 20 Mar 2025 17:02:43 -0500 Subject: [PATCH 12/64] Split logo URL more --- pyabc2/widget/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index 4197d3f..35ee2a4 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -1,6 +1,7 @@ const ABCJS_URL = 'https://cdn.jsdelivr.net/npm/abcjs@6.4.4/dist/abcjs-basic-min.js'; const ABCJS_LOGO_URL = 'https://raw.githubusercontent.com/paulrosen/abcjs/' + - 'refs/heads/main/docs/.vuepress/public/img/abcjs_comp_extended_08.svg'; + 'refs/heads/main/docs/' + + '.vuepress/public/img/abcjs_comp_extended_08.svg'; function initialize({ model }) { From c4c7d5976c8d2186cc55aff0f9a2699446e978c8 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 21 Mar 2025 10:05:06 -0500 Subject: [PATCH 13/64] fmt --- examples/widget.ipynb | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index c3c714d..a92997f 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -62,7 +62,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -73,7 +73,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -83,7 +83,7 @@ { "cell_type": "code", "execution_count": null, - "id": "be272066-b594-4a8c-ac2d-2e3961aa0e7f", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -99,7 +99,7 @@ { "cell_type": "code", "execution_count": null, - "id": "81be4139-ca8a-4c99-b2c5-9615e4eaacab", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -113,7 +113,7 @@ }, { "cell_type": "markdown", - "id": "8", + "id": "9", "metadata": {}, "source": [ "## Compare to ipywidgets behavior" @@ -122,7 +122,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -132,7 +132,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -142,7 +142,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -269,8 +269,7 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.9" + "pygments_lexer": "ipython3" } }, "nbformat": 4, From afc6c651ca8d70cf9b01a156ef2d728245169fdd Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 10:38:56 -0500 Subject: [PATCH 14/64] Add optional deps --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3ebede2..1000ac0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,12 @@ sources = [ "pandas ~=1.4", "requests ~=2.0", ] +widget = [ + "anywidget", +] +sheet = [ + "nodejs-wheel-binaries", +] test = [ "mypy", "pandas-stubs", From 7c8b032f6bee375c85e46c587897a8a09efcddd3 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 11:23:11 -0500 Subject: [PATCH 15/64] Make SVG with node (will add args) --- .gitignore | 4 ++++ pyabc2/sheet/package.json | 16 ++++++++++++++++ pyabc2/sheet/render.js | 36 ++++++++++++++++++++++++++++++++++++ pyabc2/sheet/t.py | 12 ++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 pyabc2/sheet/package.json create mode 100644 pyabc2/sheet/render.js create mode 100644 pyabc2/sheet/t.py diff --git a/.gitignore b/.gitignore index d749ce2..f95269e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,11 @@ pyabc2/sources/_* !pyabc2/sources/__init__.py poetry.lock venv*/ +*.svg +# node +node_modules +package-lock.json # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/pyabc2/sheet/package.json b/pyabc2/sheet/package.json new file mode 100644 index 0000000..060880f --- /dev/null +++ b/pyabc2/sheet/package.json @@ -0,0 +1,16 @@ +{ + "name": "pyabc2", + "version": "0.0.0", + "description": "Render ABC notation using abcjs", + "main": "render.js", + "type": "module", + "scripts": { + "start": "node render.js" + }, + "dependencies": { + "abcjs": "6.4.4", + "jsdom": "^26.0.0" + }, + "author": "", + "license": "ISC" +} diff --git a/pyabc2/sheet/render.js b/pyabc2/sheet/render.js new file mode 100644 index 0000000..ee2a662 --- /dev/null +++ b/pyabc2/sheet/render.js @@ -0,0 +1,36 @@ +// Use abcjs to render an ABC string with nodejs + +import fs from 'fs'; + +import ABCJS from 'abcjs'; +import { JSDOM } from 'jsdom'; + +const dom = new JSDOM(``); + +// We make `document` globally accessibly since abcjs uses it to create the SVG +global.document = dom.window.document; + +const XMLSerializer = dom.window.XMLSerializer; + +let div = document.createElement('div', { id: 'target' }); +document.body.appendChild(div); + +let abc = ` +K: G +M: 6/8 +BAG AGE | GED GBd | edB dgb | age dBA | +` + +// Render +ABCJS.renderAbc( + div, + abc, + { + scale: 1.0, + staffWidth: 500, + }, +) + +// Extract the SVG from the div and save it to a file +let svg = new XMLSerializer().serializeToString(div.firstChild); +fs.writeFileSync('output.svg', svg); diff --git a/pyabc2/sheet/t.py b/pyabc2/sheet/t.py new file mode 100644 index 0000000..c485e44 --- /dev/null +++ b/pyabc2/sheet/t.py @@ -0,0 +1,12 @@ +from pathlib import Path + +from nodejs_wheel import node, npm + +HERE = Path(__file__).parent + +# Build the project +# TODO: skip if already built recently-ish +ret = npm(["install", HERE.as_posix()]) + +# Run the script +ret = node(["render.js"]) From a8964abe5e1fb5a8e6f3d06f1b0e1d3ccd4da76e Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 11:37:22 -0500 Subject: [PATCH 16/64] Receive SVG str in Python --- pyabc2/sheet/render.js | 8 +++----- pyabc2/sheet/t.py | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pyabc2/sheet/render.js b/pyabc2/sheet/render.js index ee2a662..d9227a8 100644 --- a/pyabc2/sheet/render.js +++ b/pyabc2/sheet/render.js @@ -1,7 +1,5 @@ // Use abcjs to render an ABC string with nodejs -import fs from 'fs'; - import ABCJS from 'abcjs'; import { JSDOM } from 'jsdom'; @@ -12,7 +10,7 @@ global.document = dom.window.document; const XMLSerializer = dom.window.XMLSerializer; -let div = document.createElement('div', { id: 'target' }); +const div = document.createElement('div', { id: 'target' }); document.body.appendChild(div); let abc = ` @@ -32,5 +30,5 @@ ABCJS.renderAbc( ) // Extract the SVG from the div and save it to a file -let svg = new XMLSerializer().serializeToString(div.firstChild); -fs.writeFileSync('output.svg', svg); +const svg = new XMLSerializer().serializeToString(div.firstChild); +console.log(svg); diff --git a/pyabc2/sheet/t.py b/pyabc2/sheet/t.py index c485e44..eea20e8 100644 --- a/pyabc2/sheet/t.py +++ b/pyabc2/sheet/t.py @@ -1,4 +1,5 @@ from pathlib import Path +from textwrap import indent from nodejs_wheel import node, npm @@ -6,7 +7,17 @@ # Build the project # TODO: skip if already built recently-ish -ret = npm(["install", HERE.as_posix()]) +rc = npm(["install", HERE.as_posix()]) # Run the script -ret = node(["render.js"]) +cp = node( + ["render.js"], + return_completed_process=True, + capture_output=True, + text=True, +) +if cp.returncode != 0: + info = indent(cp.stderr, "| ", lambda line: True) + raise RuntimeError(f"Failed to render sheet music:\n{info}") +else: + print(cp.stdout[:100]) From b735d73f7c614aa2b924e631f45924a208f68189 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 12:13:49 -0500 Subject: [PATCH 17/64] Create function to render --- pyabc2/sheet/package.json | 4 ++-- pyabc2/sheet/render.js | 42 +++++++++++++++++++++------------------ pyabc2/sheet/t.py | 10 +++++++--- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/pyabc2/sheet/package.json b/pyabc2/sheet/package.json index 060880f..6fdf645 100644 --- a/pyabc2/sheet/package.json +++ b/pyabc2/sheet/package.json @@ -1,5 +1,5 @@ { - "name": "pyabc2", + "name": "abcjs-render", "version": "0.0.0", "description": "Render ABC notation using abcjs", "main": "render.js", @@ -11,6 +11,6 @@ "abcjs": "6.4.4", "jsdom": "^26.0.0" }, - "author": "", + "author": "zmoon", "license": "ISC" } diff --git a/pyabc2/sheet/render.js b/pyabc2/sheet/render.js index d9227a8..4d35cc5 100644 --- a/pyabc2/sheet/render.js +++ b/pyabc2/sheet/render.js @@ -3,15 +3,31 @@ import ABCJS from 'abcjs'; import { JSDOM } from 'jsdom'; -const dom = new JSDOM(``); +/** + * Render an ABC notation string to sheet music with abcjs, returning an SVG string. + * + * @param {string} abc - The ABC notation string to render. + * @param {Object.} [params] - Optional abcjs parameters + * (https://paulrosen.github.io/abcjs/visual/render-abc-options.html). + * @returns {string} SVG string. + */ +export function render(abc, params = {}) { + const dom = new JSDOM(``); -// We make `document` globally accessibly since abcjs uses it to create the SVG -global.document = dom.window.document; + // We make `document` globally accessibly since abcjs uses it to create the SVG + global.document = dom.window.document; -const XMLSerializer = dom.window.XMLSerializer; + const XMLSerializer = dom.window.XMLSerializer; -const div = document.createElement('div', { id: 'target' }); -document.body.appendChild(div); + const div = document.createElement('div', { id: 'target' }); + document.body.appendChild(div); + + // Render + ABCJS.renderAbc(div, abc, params); + + // Extract the SVG from the div and save it to a file + return new XMLSerializer().serializeToString(div.firstChild); +} let abc = ` K: G @@ -19,16 +35,4 @@ M: 6/8 BAG AGE | GED GBd | edB dgb | age dBA | ` -// Render -ABCJS.renderAbc( - div, - abc, - { - scale: 1.0, - staffWidth: 500, - }, -) - -// Extract the SVG from the div and save it to a file -const svg = new XMLSerializer().serializeToString(div.firstChild); -console.log(svg); +console.log(render(abc)); diff --git a/pyabc2/sheet/t.py b/pyabc2/sheet/t.py index eea20e8..72ff0cd 100644 --- a/pyabc2/sheet/t.py +++ b/pyabc2/sheet/t.py @@ -17,7 +17,11 @@ text=True, ) if cp.returncode != 0: - info = indent(cp.stderr, "| ", lambda line: True) + info = indent(cp.stderr, "| ", lambda _: True) raise RuntimeError(f"Failed to render sheet music:\n{info}") -else: - print(cp.stdout[:100]) + +svg = cp.stdout +print(svg[:500]) + +with open(HERE / "output.svg", "w") as f: + f.write(cp.stdout) From 9905fbfe9154bf8c6cf1e092b5fbe81f0f255866 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 13:11:09 -0500 Subject: [PATCH 18/64] Pass the ABC via stdin --- pyabc2/sheet/cli.cjs | 27 +++++++++++++++++++++++++++ pyabc2/sheet/package.json | 5 ++++- pyabc2/sheet/render.js | 8 -------- pyabc2/sheet/t.py | 9 ++++++++- 4 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 pyabc2/sheet/cli.cjs diff --git a/pyabc2/sheet/cli.cjs b/pyabc2/sheet/cli.cjs new file mode 100644 index 0000000..726c56e --- /dev/null +++ b/pyabc2/sheet/cli.cjs @@ -0,0 +1,27 @@ +const { render } = require('./render'); + +// Take ABC string as stdin +// Take parameters as --key=value flags + +// Parse command-line arguments for parameters +const params = {}; +process.argv.slice(2).forEach(arg => { + const match = arg.match(/^--([^=]+)=(.*)$/); + if (match) { + const key = match[1]; + const value = match[2]; + params[key] = value; + } +}); + +// Read ABC string from stdin +let abc = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { + abc += chunk; +}); +process.stdin.on('end', () => { + // Render the ABC string to SVG + const svg = render(abc, params); + console.log(svg); +}); diff --git a/pyabc2/sheet/package.json b/pyabc2/sheet/package.json index 6fdf645..486fb18 100644 --- a/pyabc2/sheet/package.json +++ b/pyabc2/sheet/package.json @@ -5,7 +5,10 @@ "main": "render.js", "type": "module", "scripts": { - "start": "node render.js" + "cli": "node cli.cjs" + }, + "bin": { + "abcjs-render": "./cli.cjs" }, "dependencies": { "abcjs": "6.4.4", diff --git a/pyabc2/sheet/render.js b/pyabc2/sheet/render.js index 4d35cc5..467c32b 100644 --- a/pyabc2/sheet/render.js +++ b/pyabc2/sheet/render.js @@ -28,11 +28,3 @@ export function render(abc, params = {}) { // Extract the SVG from the div and save it to a file return new XMLSerializer().serializeToString(div.firstChild); } - -let abc = ` -K: G -M: 6/8 -BAG AGE | GED GBd | edB dgb | age dBA | -` - -console.log(render(abc)); diff --git a/pyabc2/sheet/t.py b/pyabc2/sheet/t.py index 72ff0cd..65b0621 100644 --- a/pyabc2/sheet/t.py +++ b/pyabc2/sheet/t.py @@ -9,10 +9,17 @@ # TODO: skip if already built recently-ish rc = npm(["install", HERE.as_posix()]) +abc = """\ +K: G +M: 6/8 +BAG AGE | GED GBd | edB dgb | age dBA | +""" + # Run the script cp = node( - ["render.js"], + [(HERE / "cli.cjs").as_posix(), "--staffwidth=500", "--scale=0.85"], return_completed_process=True, + input=abc, capture_output=True, text=True, ) From a325e4318af57509f70b6f6323a5b81e6ace17b7 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 14:03:19 -0500 Subject: [PATCH 19/64] Python function --- pyabc2/sheet/__init__.py | 103 +++++++++++++++++++++++++++++++++++++++ pyabc2/sheet/cli.cjs | 8 ++- pyabc2/sheet/t.py | 34 ------------- pyabc2/widget/index.js | 4 +- 4 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 pyabc2/sheet/__init__.py delete mode 100644 pyabc2/sheet/t.py diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py new file mode 100644 index 0000000..8c29fe5 --- /dev/null +++ b/pyabc2/sheet/__init__.py @@ -0,0 +1,103 @@ +import datetime +import os +from pathlib import Path +from textwrap import indent + +HERE = Path(__file__).parent + +ALWAYS_BUILD = os.getenv("PYABC_ALWAYS_BUILD_NPM_PACKAGE", "0") == "1" + + +def build(): + try: + from nodejs_wheel import npm + except ImportError as e: + raise RuntimeError( + "The 'nodejs-wheel-binaries' package is required " + "to render sheet music outside of Jupyter. " + "It is included with the pyabc2 'sheet' extra." + ) from e + + rc = npm(["install", HERE.as_posix()]) + if rc != 0: + raise RuntimeError("Build failed") + + +def _maybe_build(): + now = datetime.datetime.now().timestamp() + package_lock = HERE / "package-lock.json" + if ALWAYS_BUILD or ( + package_lock.exists() and now - package_lock.stat().st_mtime > 7 * 24 * 3600 + ): + build() + + +def svg( + abc: str, + *, + # Keep names consistent with the widget! + scale: float = 1.0, + staff_width: int = 600, + **kwargs, +) -> str: + """Render ABC notation to SVG sheet music using abcjs. + + Parameters + ---------- + abc + The ABC notation to render. + **kwargs + Additional abcjs options that haven't been explicitly defined here + in the signature. + https://paulrosen.github.io/abcjs/visual/render-abc-options.html + """ + from nodejs_wheel import node + + _maybe_build() + + params = { + **kwargs, + "scale": scale, + "staffwidth": staff_width, + } + + # Run the script + cmd = [(HERE / "cli.cjs").as_posix()] + for k, v in params.items(): + cmd.append(f"--{k}={v}") + cp = node( + cmd, + return_completed_process=True, + input=abc, + capture_output=True, + text=True, + ) + if cp.returncode != 0: + info = indent(cp.stderr, "| ", lambda _: True) + raise RuntimeError(f"Failed to render sheet music:\n{info}") + + assert isinstance(cp.stdout, str) + + return cp.stdout + + +if __name__ == "__main__": + abc = """\ + K: G + M: 6/8 + BAG AGE | GED GBd | edB dgb | age dBA | + """ + # Note: the indent is ignored by abcjs + + svg_str = svg( + abc, + scale=3, + staff_width=800, + foregroundColor="blue", + lineThickness=0.2, + ) + + print(svg_str[:500]) + + with open(HERE / "output.svg", "w") as f: + f.write(svg_str) diff --git a/pyabc2/sheet/cli.cjs b/pyabc2/sheet/cli.cjs index 726c56e..e27baa5 100644 --- a/pyabc2/sheet/cli.cjs +++ b/pyabc2/sheet/cli.cjs @@ -9,7 +9,13 @@ process.argv.slice(2).forEach(arg => { const match = arg.match(/^--([^=]+)=(.*)$/); if (match) { const key = match[1]; - const value = match[2]; + let value = match[2]; + + // Convert string value to number + if (!isNaN(value)) { + value = parseFloat(value); + } + params[key] = value; } }); diff --git a/pyabc2/sheet/t.py b/pyabc2/sheet/t.py deleted file mode 100644 index 65b0621..0000000 --- a/pyabc2/sheet/t.py +++ /dev/null @@ -1,34 +0,0 @@ -from pathlib import Path -from textwrap import indent - -from nodejs_wheel import node, npm - -HERE = Path(__file__).parent - -# Build the project -# TODO: skip if already built recently-ish -rc = npm(["install", HERE.as_posix()]) - -abc = """\ -K: G -M: 6/8 -BAG AGE | GED GBd | edB dgb | age dBA | -""" - -# Run the script -cp = node( - [(HERE / "cli.cjs").as_posix(), "--staffwidth=500", "--scale=0.85"], - return_completed_process=True, - input=abc, - capture_output=True, - text=True, -) -if cp.returncode != 0: - info = indent(cp.stderr, "| ", lambda _: True) - raise RuntimeError(f"Failed to render sheet music:\n{info}") - -svg = cp.stdout -print(svg[:500]) - -with open(HERE / "output.svg", "w") as f: - f.write(cp.stdout) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index 35ee2a4..5226305 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -52,7 +52,7 @@ function render({ model, el }) { let showDebugGrid = () => model.get('debug_grid'); let showDebugInput = () => model.get('debug_input'); let showLogo = () => model.get('logo'); - let staffWidth = () => model.get('staff_width'); + let staffwidth = () => model.get('staff_width'); let visualTranspose = () => model.get('transpose'); let active_music_ids = model.get("_active_music_ids"); @@ -120,7 +120,7 @@ function render({ model, el }) { lineThickness: lineThickness(), scale: scale(), showDebug: showDebug, - staffwidth: staffWidth(), + staffwidth: staffwidth(), visualTranspose: visualTranspose(), }, ); From 359898264b4666622eb14981b13d78fb869b2153 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 14:26:41 -0500 Subject: [PATCH 20/64] Convert SVG to other formats with cairosvg --- pyabc2/sheet/__init__.py | 27 +++++++++++++++++++++++++-- pyproject.toml | 1 + 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 8c29fe5..25a7c72 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -14,7 +14,7 @@ def build(): except ImportError as e: raise RuntimeError( "The 'nodejs-wheel-binaries' package is required " - "to render sheet music outside of Jupyter. " + "to render sheet music in the background with abcjs via Node.js. " "It is included with the pyabc2 'sheet' extra." ) from e @@ -81,6 +81,25 @@ def svg( return cp.stdout +def svg_to(svg: str, fmt: str, **kwargs) -> bytes: + """Convert an SVG string to another format, returning bytes.""" + try: + import cairosvg + except ImportError as e: + raise RuntimeError( + "The 'cairosvg' package is required to convert SVG to other formats." + ) from e + + try: + func = getattr(cairosvg, f"svg2{fmt.lower()}") + except AttributeError: + raise ValueError( + f"Unsupported format: {fmt!r}. Supported formats include: 'png', 'pdf', 'ps', 'svg'." + ) + + return func(bytestring=svg, **kwargs) + + if __name__ == "__main__": abc = """\ K: G @@ -99,5 +118,9 @@ def svg( print(svg_str[:500]) - with open(HERE / "output.svg", "w") as f: + with open(HERE / "test.svg", "w") as f: f.write(svg_str) + + for fmt in ["png", "PDF"]: + with open(f"test.{fmt}", "wb") as f: + f.write(svg_to(svg_str, fmt)) diff --git a/pyproject.toml b/pyproject.toml index 1000ac0..b838c88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ widget = [ "anywidget", ] sheet = [ + "cairosvg", "nodejs-wheel-binaries", ] test = [ From fdeb8e546353bc8f0fea88c6109f0e60a0bba2da Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 14:31:28 -0500 Subject: [PATCH 21/64] Tweak docstring --- pyabc2/sheet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 25a7c72..67899cb 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -40,7 +40,7 @@ def svg( staff_width: int = 600, **kwargs, ) -> str: - """Render ABC notation to SVG sheet music using abcjs. + """Render ABC notation to sheet music using abcjs, returning SVG string. Parameters ---------- From ae56fef2af01c546d7487d16e6ef98726b525757 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 14:33:39 -0500 Subject: [PATCH 22/64] Update gitignore for test files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f95269e..5efd1f4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ pyabc2/sources/_* !pyabc2/sources/__init__.py poetry.lock venv*/ -*.svg +examples/music*.svg +pyabc2/sheet/test.* # node node_modules From 37df25e43408b7caeeda940358e53258e5289045 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 15:06:37 -0500 Subject: [PATCH 23/64] Document options --- pyabc2/sheet/__init__.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 67899cb..667273f 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -1,5 +1,6 @@ import datetime import os +import warnings from pathlib import Path from textwrap import indent @@ -82,7 +83,23 @@ def svg( def svg_to(svg: str, fmt: str, **kwargs) -> bytes: - """Convert an SVG string to another format, returning bytes.""" + """Convert an SVG string to another format, returning bytes. + + Parameters + ---------- + svg + The SVG string to convert. + fmt + The format to convert to, e.g., 'png', 'pdf', 'ps', 'svg'. + **kwargs + Passed to the `cairgosvg function `__. + Options include: + + - ``scale`` + - ``dpi`` + - ``parent_width``, ``parent_height`` (for SVGs using percentages) + - ``output_width``, ``output_height`` + """ try: import cairosvg except ImportError as e: @@ -90,6 +107,12 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: "The 'cairosvg' package is required to convert SVG to other formats." ) from e + to_remove = ["url", "file_obj", "write_to"] + for key in to_remove: + if key in kwargs: + warnings.warn(f"Keyword {key!r} is not supported and will be ignored.", stacklevel=2) + del kwargs[key] + try: func = getattr(cairosvg, f"svg2{fmt.lower()}") except AttributeError: @@ -123,4 +146,4 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: for fmt in ["png", "PDF"]: with open(f"test.{fmt}", "wb") as f: - f.write(svg_to(svg_str, fmt)) + f.write(svg_to(svg_str, fmt, write_to="asdf")) From 92fd7378d98bbac3844fcb97b10a2ddd14b006e2 Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 17:42:28 -0500 Subject: [PATCH 24/64] Move compose with ipywidgets to module, update examples --- examples/widget.ipynb | 183 ++++++++++++++++++-------------------- pyabc2/widget/__init__.py | 136 ++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 98 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index a92997f..768f556 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -10,18 +10,15 @@ "import ipywidgets as ipw\n", "from IPython.display import SVG\n", "\n", - "from pyabc2.widget import ABCJSWidget" + "from pyabc2.widget import ABCJSWidget, interactive" ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "id": "1", "metadata": {}, - "outputs": [], "source": [ - "w = ABCJSWidget(logo=True)\n", - "w" + "## The display widget" ] }, { @@ -31,7 +28,8 @@ "metadata": {}, "outputs": [], "source": [ - "w.abc = \"ABCD\"" + "# Nothing\n", + "ABCJSWidget()" ] }, { @@ -41,12 +39,8 @@ "metadata": {}, "outputs": [], "source": [ - "w = ABCJSWidget()\n", - "w.abc = \"\"\"\\\n", - "K: G\n", - "G,A,B,C DEFG | ABcd efga | bc'd'e' f'g'a'\n", - "\"\"\"\n", - "display(w)" + "# Really nothing\n", + "ABCJSWidget(hide=True)" ] }, { @@ -56,7 +50,8 @@ "metadata": {}, "outputs": [], "source": [ - "w" + "# Just logo\n", + "ABCJSWidget(logo=True)" ] }, { @@ -66,8 +61,8 @@ "metadata": {}, "outputs": [], "source": [ - "w.abc += \"c'\" # All display instances are modified!\n", - "w" + "# Logo and a bit of music\n", + "ABCJSWidget(abc=\"DEFG E2 CD- | D8 ||\", logo=True)" ] }, { @@ -77,7 +72,8 @@ "metadata": {}, "outputs": [], "source": [ - "w" + "# Slightly more complicated example\n", + "ABCJSWidget(abc=\"K:G\\n\" + \" G,A,B,C DEFG | \" * 4, staff_width=500, foreground=\"teal\")" ] }, { @@ -87,13 +83,15 @@ "metadata": {}, "outputs": [], "source": [ - "w = ABCJSWidget(hide=True)\n", + "# Adding ABC and modifying parameters after init\n", + "w = ABCJSWidget()\n", "w.abc = \"\"\"\\\n", "K: G\n", - "G,A,B,C DEFG | ABcd efga | bc'd'e' f'g'a'\n", + "G,A,B,C DEFG | ABcd efga | bc'd'e' f'g'a'b' |\n", "\"\"\"\n", - "display(w)\n", - "print(w.svgs) # NOTE: Empty at this point" + "w.scale = 0.8\n", + "w.staff_width = 550\n", + "display(w)" ] }, { @@ -103,12 +101,10 @@ "metadata": {}, "outputs": [], "source": [ - "# NOTE: empty the first time this cell is called if using restart and run to selected cell\n", - "print(len(w.svgs), \"SVG(s)\")\n", - "for i, s in enumerate(w.svgs):\n", - " display(SVG(s))\n", - " with open(f\"music{i}.svg\", \"w\") as f:\n", - " f.write(s)" + "# Dynamic update\n", + "# Note both display instances are modified!\n", + "w.abc += \"| c'\"\n", + "w" ] }, { @@ -116,7 +112,10 @@ "id": "9", "metadata": {}, "source": [ - "## Compare to ipywidgets behavior" + "## Save the SVG from the display widget\n", + "\n", + "It doesn't seem to be possible to do this with a single cell,\n", + "but with two cells we can get to the SVGs." ] }, { @@ -126,7 +125,13 @@ "metadata": {}, "outputs": [], "source": [ - "slider = ipw.FloatSlider(min=200, max=500, value=400)" + "w = ABCJSWidget(hide=True)\n", + "w.abc = \"\"\"\\\n", + "K: G\n", + "G,A,B,C DEFG | ABcd efga | bc'd'e' f'g'a'\n", + "\"\"\"\n", + "display(w)\n", + "print(w.svgs) # NOTE: Empty at this point" ] }, { @@ -136,17 +141,18 @@ "metadata": {}, "outputs": [], "source": [ - "slider" + "# NOTE: must run this cell again for `.svgs` to be populated if using restart and run (to selected cell or full)\n", + "print(len(w.svgs), \"SVG(s)\")\n", + "for i, s in enumerate(w.svgs):\n", + " display(SVG(s))" ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "id": "12", "metadata": {}, - "outputs": [], "source": [ - "slider" + "## Editor" ] }, { @@ -156,9 +162,7 @@ "metadata": {}, "outputs": [], "source": [ - "# We can do this and all display instances change and the slider still shows for this cell\n", - "slider.value += 1\n", - "slider" + "interactive()" ] }, { @@ -166,7 +170,11 @@ "id": "14", "metadata": {}, "source": [ - "## Compose with ipywidgets" + "## Editor example\n", + "\n", + "With a single widget instance, both display instances _should_ update when you change something.\n", + "\n", + "Here, we initialize the widget with some ABC and a few non-default settings." ] }, { @@ -176,81 +184,60 @@ "metadata": {}, "outputs": [], "source": [ - "abc = \"K:C\\nT:The best scale\\n\" + \"CDEF GABc | \" * 4\n", - "w = ABCJSWidget(abc=abc, foreground=\"#303030\")\n", - "\n", - "input_box = ipw.Textarea(value=None, placeholder=\"Type something\", layout={\"width\": \"500px\", \"height\": \"5rem\"})\n", - "\n", - "width_slider = ipw.IntSlider(min=300, max=1000, value=740, description=\"Staff width (px?)\")\n", - "line_thickness_slider = ipw.FloatSlider(min=-0.4, max=2, step=0.05, value=0, description=\"Line thickness increase factor\")\n", - "scale_slider = ipw.FloatSlider(min=0.2, max=3, value=1, description=\"Scaling factor\")\n", - "transpose_slider = ipw.IntSlider(min=-12, max=12, value=0, description=\"Transpose (half steps)\")\n", - "foreground_picker = ipw.ColorPicker(\n", - " concise=False,\n", - " description=\"Foreground color\",\n", - " value=w.foreground,\n", - ")\n", - "\n", - "save_button = ipw.Button(description=\"Save\", tooltip=\"Save to SVG\")\n", - "\n", - "# logo_cbox = ipw.Checkbox(description=\"Add logo\", indent=False) # currently have to decide at init\n", - "debug_box_cbox = ipw.Checkbox(description=\"Box\", indent=False)\n", - "debug_grid_cbox = ipw.Checkbox(description=\"Grid\", indent=False)\n", - "debug_input_cbox = ipw.Checkbox(description=\"Input\", indent=False)\n", - "\n", - "ipw.link((w, \"abc\"), (input_box, \"value\"))\n", - "ipw.link((w, \"staff_width\"), (width_slider, \"value\"))\n", - "ipw.link((w, \"scale\"), (scale_slider, \"value\"))\n", - "ipw.link((w, \"line_thickness_increase\"), (line_thickness_slider, \"value\"))\n", - "ipw.link((w, \"transpose\"), (transpose_slider, \"value\"))\n", - "ipw.link((w, \"foreground\"), (foreground_picker, \"value\"))\n", - "# ipw.link((w, \"logo\"), (logo_cbox, \"value\"))\n", - "ipw.link((w, \"debug_box\"), (debug_box_cbox, \"value\"))\n", - "ipw.link((w, \"debug_grid\"), (debug_grid_cbox, \"value\"))\n", - "ipw.link((w, \"debug_input\"), (debug_input_cbox, \"value\"))\n", + "abc = \"K: C\\nT: The best scale\\n\" + \"CDEF GABc | \" * 4\n", "\n", - "def save(btn):\n", - " for i, s in enumerate(w.svgs):\n", - " with open(f\"music{i}.svg\", \"w\") as f:\n", - " f.write(s)\n", - "\n", - "save_button.on_click(save)\n", - "\n", - "ipw.VBox([\n", - " input_box,\n", - " width_slider,\n", - " scale_slider,\n", - " line_thickness_slider,\n", - " transpose_slider,\n", - " foreground_picker,\n", - " # logo_cbox,\n", - " w,\n", - " save_button,\n", - " ipw.HBox([\n", - " ipw.Label(value=\"Debug options:\", layout={\"width\": \"100px\"}),\n", - " debug_box_cbox,\n", - " debug_grid_cbox,\n", - " debug_input_cbox,\n", - " ]),\n", - "])" + "w = interactive(abc, foreground=\"#303030\", staff_width=600)\n", + "w" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "16", "metadata": {}, + "outputs": [], "source": [ - "## Set params in init" + "w" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "### Compare to ipywidgets behavior" ] }, { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "18", "metadata": {}, "outputs": [], "source": [ - "ABCJSWidget(abc=\"K:G\\n\" + \" G,A,B,C DEFG | \" * 4, staff_width=200)" + "slider = ipw.FloatSlider(min=200, max=500, value=400)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "slider" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "# We can do this and all display instances change and the slider still shows for this cell\n", + "slider.value += 1\n", + "slider" ] } ], diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index a93e1cb..b43328f 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -3,6 +3,10 @@ """ from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import ipywidgets import anywidget import traitlets @@ -33,3 +37,135 @@ class ABCJSWidget(anywidget.AnyWidget): scale = traitlets.Float(1.0).tag(sync=True) staff_width = traitlets.Integer(740).tag(sync=True) transpose = traitlets.Integer(0).tag(sync=True) + + +def interactive(abc: str = "", **kwargs) -> "ipywidgets.Widget": + """Return a Jupyter widget for interactive use, using ipywidgets.""" + import ipywidgets as ipw + + w = ABCJSWidget(abc=abc, **kwargs) + w.foreground = "black" + + slider_kws = dict( + layout={"width": "500px"}, + style={"description_width": "125px"}, + ) + input_box = ipw.Textarea( + value=None, + placeholder="Type something", + layout={"width": "500px", "height": "5rem"}, + ) + width_slider = ipw.IntSlider( + min=100, + max=2000, + value=740, + description="Staff width (px)", + **slider_kws, + ) + line_thickness_slider = ipw.FloatSlider( + min=-0.4, + max=2, + step=0.05, + value=0, + description="Line thickness factor", + **slider_kws, + ) + scale_slider = ipw.FloatSlider( + min=0.2, + max=3, + value=1, + description="Scaling factor", + **slider_kws, + ) + transpose_slider = ipw.IntSlider( + min=-24, + max=24, + value=0, + description="Transpose (half steps)", + **slider_kws, + ) + foreground_picker = ipw.ColorPicker( + concise=False, + description="Foreground color", + value=w.foreground, + style={"description_width": "130px"}, + ) + + # logo_cbox = ipw.Checkbox(description="Add logo", indent=False) # currently have to decide at init + debug_label = ipw.Label(value="Display debug options:", layout={"width": "180px"}) + debug_box_cbox = ipw.Checkbox(description="Box", indent=False) + debug_grid_cbox = ipw.Checkbox(description="Grid", indent=False) + debug_input_cbox = ipw.Checkbox(description="Input", indent=False) + + ipw.link((w, "abc"), (input_box, "value")) + ipw.link((w, "staff_width"), (width_slider, "value")) + ipw.link((w, "scale"), (scale_slider, "value")) + ipw.link((w, "line_thickness_increase"), (line_thickness_slider, "value")) + ipw.link((w, "transpose"), (transpose_slider, "value")) + ipw.link((w, "foreground"), (foreground_picker, "value")) + # ipw.link((w, "logo"), (logo_cbox, "value")) + ipw.link((w, "debug_box"), (debug_box_cbox, "value")) + ipw.link((w, "debug_grid"), (debug_grid_cbox, "value")) + ipw.link((w, "debug_input"), (debug_input_cbox, "value")) + + save_name_input = ipw.Text( + description="Save as", + value="music.svg", + placeholder="filename", + ) + save_overwrite_cbox = ipw.Checkbox( + description="Overwrite", + indent=False, + ) + save_button = ipw.Button( + description="Save", + tooltip="Save to SVG", + ) + + def save(_): + # TODO: HTML option? + p = Path.cwd() / save_name_input.value + if not p.suffix.lower() == ".svg": + p = p.with_suffix(".svg") + if p.exists() and not save_overwrite_cbox.value: + raise FileExistsError(f"{p} exists, check 'Overwrite' to replace.") + if not w.svgs: + raise ValueError("Nothing to save.") + elif len(w.svgs) == 1: + with open(p, "w") as f: + f.write(w.svgs[0]) + else: + raise ValueError("Multiple SVGs") + + save_button.on_click(save) + + layout = ipw.VBox( + [ + input_box, + width_slider, + scale_slider, + line_thickness_slider, + transpose_slider, + foreground_picker, + # logo_cbox, + w, + ipw.HBox( + [ + save_name_input, + save_overwrite_cbox, + save_button, + ], + # layout={"justify_content": "space-between"}, + ), + ipw.HBox( + [ + debug_label, + debug_box_cbox, + debug_grid_cbox, + debug_input_cbox, + ] + ), + ] + ) + + return layout From b96befe97b90da262d7a2f3793025b19a9f6919f Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 17:49:34 -0500 Subject: [PATCH 25/64] From fresh, first cell should show logo --- examples/widget.ipynb | 47 ++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/examples/widget.ipynb b/examples/widget.ipynb index 768f556..62d7127 100644 --- a/examples/widget.ipynb +++ b/examples/widget.ipynb @@ -28,7 +28,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Nothing\n", + "# Nothing (or logo if abcjs hasn't been loaded yet)\n", "ABCJSWidget()" ] }, @@ -38,6 +38,17 @@ "id": "3", "metadata": {}, "outputs": [], + "source": [ + "# Nothing\n", + "ABCJSWidget()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], "source": [ "# Really nothing\n", "ABCJSWidget(hide=True)" @@ -46,7 +57,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -57,7 +68,7 @@ { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -68,7 +79,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -79,7 +90,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -97,7 +108,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -109,7 +120,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "10", "metadata": {}, "source": [ "## Save the SVG from the display widget\n", @@ -121,7 +132,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -137,7 +148,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -149,7 +160,7 @@ }, { "cell_type": "markdown", - "id": "12", + "id": "13", "metadata": {}, "source": [ "## Editor" @@ -158,7 +169,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -167,7 +178,7 @@ }, { "cell_type": "markdown", - "id": "14", + "id": "15", "metadata": {}, "source": [ "## Editor example\n", @@ -180,7 +191,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -193,7 +204,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -202,7 +213,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "18", "metadata": {}, "source": [ "### Compare to ipywidgets behavior" @@ -211,7 +222,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -221,7 +232,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -231,7 +242,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ From ada7c0edc790adcfc831f53f4c59572fc4352a2f Mon Sep 17 00:00:00 2001 From: zmoon Date: Mon, 24 Mar 2025 18:02:31 -0500 Subject: [PATCH 26/64] Use the widget for Tune _repr_html_ --- pyabc2/_util.py | 11 +++++++++++ pyabc2/parse.py | 45 +++------------------------------------------ 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/pyabc2/_util.py b/pyabc2/_util.py index ce06481..96060ee 100644 --- a/pyabc2/_util.py +++ b/pyabc2/_util.py @@ -17,3 +17,14 @@ def get_logger(name: str) -> logging.Logger: logger.addHandler(sh) return logger + + +def in_jupyter() -> bool: + # Reference: https://stackoverflow.com/a/47428575 + try: + from IPython.core import getipython # type: ignore + except (ImportError, ModuleNotFoundError): + return False + + # + return "zmqshell" in str(type(getipython.get_ipython())) diff --git a/pyabc2/parse.py b/pyabc2/parse.py index 031e553..40d8541 100644 --- a/pyabc2/parse.py +++ b/pyabc2/parse.py @@ -140,31 +140,6 @@ def _gen_info_field_table() -> Dict[str, InfoField]: """ -def load_abcjs() -> None: - """Load abcjs into Jupyter from CDN using IPython display.""" - from IPython.display import HTML, display # type: ignore - - html = HTML(_FMT_ABCJS_LOAD_HTML.format(abcjs_version=_ABCJS_VERSION)) - display(html) - - -def _in_jupyter() -> bool: - # Reference: https://stackoverflow.com/a/47428575 - try: - from IPython.core import getipython # type: ignore - except (ImportError, ModuleNotFoundError): - return False - - # - return "zmqshell" in str(type(getipython.get_ipython())) - - -def _load_abcjs_if_in_jupyter() -> None: - if _in_jupyter(): - load_abcjs() - print("abcjs loaded") - - def _find_first_chord(s: str) -> Optional[str]: """Search for first chord spec in an ABC body portion. @@ -365,25 +340,11 @@ def __hash__(self): return hash(self.abc) def _repr_html_(self): - import uuid - - notation_id = str(uuid.uuid4()) - abc = "\\n".join(line for line in self.abc.strip().splitlines()) - - # return _fmt_abcjs.format(abc=abc, notation_id=notation_id, abcjs_version=_ABCJS_VERSION) - - # It seems that if I just return + + + +
+{abc:s} +
+ + + +""" + + +def html( + abc: str, + *, + # + title: str = "abc", + # Keep names consistent with the widget! + scale: float = 1.0, + staff_width: int = 740, + **kwargs, +): + """Generate an HTML page to render ABC with abcjs, returning string. + + Parameters + ---------- + abc + The ABC notation to render. + **kwargs + Additional abcjs options that haven't been explicitly defined here + in the signature. + https://paulrosen.github.io/abcjs/visual/render-abc-options.html + """ + params = { + **kwargs, + "scale": scale, + "staffwidth": staff_width, + } + + ind = 6 + abc_indented = "\n".join(" " * ind + line.lstrip() for line in abc.strip().splitlines()) + + ind = 6 + s_param_lines = "\n".join(" " * (ind + 2) + f"{k}: {v!r}," for k, v in sorted(params.items())) + s_params = r"{" + "\n" + s_param_lines + "\n" + " " * ind + "}" + + s = FULLPAGE_TPL.format( + title=title, + abc=abc_indented, + params=s_params, + ) + + return s + + +def open_html( + *args, + **kwargs, +) -> None: + """Generate an HTML page to render ABC with abcjs + and open it in a new tab with the default web browser.""" + from tempfile import NamedTemporaryFile + from webbrowser import open_new_tab + + s = html(*args, **kwargs) + with NamedTemporaryFile("w", suffix=".html", delete=False) as f: + f.write(s) + path = f.name + + open_new_tab(path) + + +if __name__ == "__main__": # pragma: no cover + abc = """\ + T: Awesome Tune + K: G + M: 6/8 + BAG AGE | GED GBd | edB dgb | age dBA | + """ + # Note: the indent is ignored by abcjs + + open_html(abc, title="abcjs!", scale=3, foregroundColor="forestgreen") diff --git a/pyabc2/parse.py b/pyabc2/parse.py index 40d8541..cc580fb 100644 --- a/pyabc2/parse.py +++ b/pyabc2/parse.py @@ -97,49 +97,6 @@ def _gen_info_field_table() -> Dict[str, InfoField]: TUNE_INLINE_FIELD_KEYS = {k for k, v in INFO_FIELDS.items() if v.allowed_in_tune_inline} -_ABCJS_VERSION = "6.0.0-beta.33" - -_FMT_ABCJS_COMPLETE_PAGE_HTML = """\ - - - - - {title:s} - - - -
- - - - -""" - -_FMT_ABCJS_LOAD_HTML = """\ - -""" - -_FMT_ABCJS_BODY_HTML = """\ -
hi
- - -""" - -_FMT_ABCJS_RENDER_JS = """\ -const tune = "{abc:s}"; -const params = {{}}; -ABCJS.renderAbc("notation-{notation_id:s}", tune, params); -""" - - def _find_first_chord(s: str) -> Optional[str]: """Search for first chord spec in an ABC body portion. diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 667273f..4441f99 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -123,8 +123,9 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: return func(bytestring=svg, **kwargs) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover abc = """\ + T: Awesome Tune K: G M: 6/8 BAG AGE | GED GBd | edB dgb | age dBA | From ebf7d7fcd7a5bcb07e85edebf520289f3330b580 Mon Sep 17 00:00:00 2001 From: zmoon Date: Tue, 25 Mar 2025 09:16:03 -0500 Subject: [PATCH 28/64] Add other widget deps, tweak abcjs mentions --- pyabc2/widget/index.js | 8 ++++---- pyproject.toml | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index 5226305..b0cc1ba 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -12,7 +12,7 @@ function initialize({ model }) { return new Promise((resolve, reject) => { if (window.ABCJS) { model.set("_first_load", false); - console.log('ABCJS already loaded'); + console.log('abcjs already loaded'); resolve(); return; }; @@ -21,11 +21,11 @@ function initialize({ model }) { let script = document.createElement('script'); script.src = ABCJS_URL; script.onload = () => { - console.log('ABCJS loaded'); + console.log('abcjs loaded'); resolve(); }; script.onerror = () => { - console.error('Failed to load ABCJS'); + console.error('Failed to load abcjs'); reject(); }; document.head.appendChild(script); @@ -71,7 +71,7 @@ function render({ model, el }) { let head = document.createElement('div'); - // ABCJS render target + // abcjs render target let music = document.createElement('div'); let music_id = 'music' + '-' + getRandomString(); music.id = music_id; diff --git a/pyproject.toml b/pyproject.toml index b838c88..d7f2378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ sources = [ ] widget = [ "anywidget", + "ipywidgets", + "traitlets", ] sheet = [ "cairosvg", From 0eb00bfde51522fb7dfa5e54c531e148705c4e87 Mon Sep 17 00:00:00 2001 From: zmoon Date: Tue, 25 Mar 2025 09:27:00 -0500 Subject: [PATCH 29/64] Add docstring for sheet --- pyabc2/sheet/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 4441f99..5f45884 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -1,3 +1,5 @@ +"""Render ABC notation to SVG sheet music using abcjs in the background.""" + import datetime import os import warnings From 6c6f64e43ca12d20b07e4076701cf358afb06491 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 16:07:24 -0600 Subject: [PATCH 30/64] Specify prefix correctly; better build need check --- pyabc2/sheet/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 5f45884..6cbf737 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -21,7 +21,7 @@ def build(): "It is included with the pyabc2 'sheet' extra." ) from e - rc = npm(["install", HERE.as_posix()]) + rc = npm(["install", "--prefix", HERE.as_posix()]) if rc != 0: raise RuntimeError("Build failed") @@ -29,8 +29,12 @@ def build(): def _maybe_build(): now = datetime.datetime.now().timestamp() package_lock = HERE / "package-lock.json" - if ALWAYS_BUILD or ( - package_lock.exists() and now - package_lock.stat().st_mtime > 7 * 24 * 3600 + node_modules = HERE / "node_modules" + if ( + not package_lock.exists() + or not node_modules.exists() + or (package_lock.exists() and now - package_lock.stat().st_mtime > 7 * 24 * 3600) + or ALWAYS_BUILD ): build() From cd9198fac10e97c9026180141bb75cbdee48a093 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 16:27:25 -0600 Subject: [PATCH 31/64] Add widget nb to docs build --- docs/examples/widget.ipynb | 52 ++++++++++++++++++++++---------------- docs/index.md | 1 + 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/docs/examples/widget.ipynb b/docs/examples/widget.ipynb index 62d7127..7a53520 100644 --- a/docs/examples/widget.ipynb +++ b/docs/examples/widget.ipynb @@ -1,9 +1,17 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Widget" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "0", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -15,7 +23,7 @@ }, { "cell_type": "markdown", - "id": "1", + "id": "2", "metadata": {}, "source": [ "## The display widget" @@ -24,7 +32,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -35,7 +43,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -46,7 +54,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -57,7 +65,7 @@ { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -68,7 +76,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -79,7 +87,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -90,7 +98,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -108,7 +116,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -120,7 +128,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "11", "metadata": {}, "source": [ "## Save the SVG from the display widget\n", @@ -132,7 +140,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -148,7 +156,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -160,7 +168,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "14", "metadata": {}, "source": [ "## Editor" @@ -169,7 +177,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -178,7 +186,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "## Editor example\n", @@ -191,7 +199,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -204,7 +212,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -213,7 +221,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "19", "metadata": {}, "source": [ "### Compare to ipywidgets behavior" @@ -222,7 +230,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -232,7 +240,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -242,7 +250,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ diff --git a/docs/index.md b/docs/index.md index 11e9ec7..43b64c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,7 @@ examples/types.ipynb examples/modes.ipynb examples/sources.ipynb examples/plots.ipynb +examples/widget.ipynb ``` ```{toctree} From 5785f2c3ff0e761025406e5abe348504b78326fc Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 18:03:04 -0600 Subject: [PATCH 32/64] Get tune display in docs working with the new widget approach --- docs/conf.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 8bbd8ea..cf73bd9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,6 +34,18 @@ html_theme = "furo" html_static_path = ["_static"] html_css_files = ["custom.css"] +html_js_files = [ + # TODO: just the pages that need it (example notebooks) instead of every page, using `app.add_js_file` + ( + "https://cdn.jsdelivr.net/npm/abcjs@6.4.4/dist/abcjs-basic-min.js", + { + "crossorigin": "anonymous", + # We need it to load before the widget instances + # which seem to be in the extensions group (default priority 500) + "priority": 499, + }, + ) +] napoleon_google_docstring = False napoleon_numpy_docstring = True From 384d8061a15427f3b97e60ab40992fc255fd9470 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 18:25:39 -0600 Subject: [PATCH 33/64] Add width (and title) to abcjs logo img just height was enough to set the size in JupyterLab, but not in the docs build apparently --- pyabc2/widget/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index b0cc1ba..e785e25 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -66,6 +66,8 @@ function render({ model, el }) { let logo = document.createElement('img'); logo.src = ABCJS_LOGO_URL; logo.height = '24'; + logo.width = '228'; + logo.title = 'abcjs logo'; container.appendChild(logo); } From 8475f1ea8fe60d9333a480ca4082efc648f59758 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 18:33:36 -0600 Subject: [PATCH 34/64] Stay on Sphinx 8 for now to avoid the many myst-nb warnings about things removed in Sphinx 10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2df6ddd..2e0e648 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ doc = [ "furo", "matplotlib", "myst-nb", - "sphinx", + "sphinx ==8.*", "sphinx-copybutton", "sphinx-inline-tabs", ] From f221e440a6943b60c1b33c3783673571b9b0bfbf Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 19:49:52 -0600 Subject: [PATCH 35/64] notes; svg example --- docs/examples/types.ipynb | 2 ++ docs/examples/widget.ipynb | 61 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/examples/types.ipynb b/docs/examples/types.ipynb index 33d3218..f58d771 100644 --- a/docs/examples/types.ipynb +++ b/docs/examples/types.ipynb @@ -333,6 +333,8 @@ "id": "32", "metadata": {}, "source": [ + "(tune_type)=\n", + "\n", "## Tune\n", "\n", "Pass an ABC string to create a {class}`~pyabc2.Tune`." diff --git a/docs/examples/widget.ipynb b/docs/examples/widget.ipynb index 7a53520..8e73a33 100644 --- a/docs/examples/widget.ipynb +++ b/docs/examples/widget.ipynb @@ -26,7 +26,9 @@ "id": "2", "metadata": {}, "source": [ - "## The display widget" + "## The display widget\n", + "\n", + "Used to {ref}`display ` {class}`~pyabc2.Tune`s in the notebook environment." ] }, { @@ -134,7 +136,8 @@ "## Save the SVG from the display widget\n", "\n", "It doesn't seem to be possible to do this with a single cell,\n", - "but with two cells we can get to the SVGs." + "but with two cells we can get to the SVGs\n", + "(based on experiments in JupyterLab)." ] }, { @@ -171,7 +174,12 @@ "id": "14", "metadata": {}, "source": [ - "## Editor" + "## Editor\n", + "\n", + "```{note}\n", + "The docs version doesn't currently provide full interactivity\n", + "(a running Python kernel is needed, e.g. JupyterLab).\n", + "```" ] }, { @@ -258,6 +266,53 @@ "slider.value += 1\n", "slider" ] + }, + { + "cell_type": "markdown", + "id": "23", + "metadata": {}, + "source": [ + "## Headless\n", + "\n", + "We can also use abcjs in the background via Node.js using {mod}`pyabc2.sheet`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import SVG, Image\n", + "\n", + "from pyabc2.sheet import svg, svg_to" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "s = svg(\"CDEF GABc |\")\n", + "print(s[:100], \"...\")\n", + "SVG(s)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " display(Image(data=svg_to(s, \"jpg\")))\n", + "except OSError:\n", + " print(\"failed to load cairo\")" + ] } ], "metadata": { From 1531e557db6c4cb0efb0528f6ba2226430f3f66f Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:07:35 -0600 Subject: [PATCH 36/64] Update API doc --- docs/api.rst | 24 ++++++++++++++++++++++++ pyabc2/sheet/__init__.py | 8 +++++++- pyabc2/widget/__init__.py | 4 +++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 1078a45..e88c43e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -150,3 +150,27 @@ Functions: eskin.load_url eskin.abctools_url_to_abc eskin.abc_to_abctools_url + +Widget +====== + +.. automodule:: pyabc2.widget + +.. autosummary:: + :toctree: api/ + + ABCJSWidget + interactive + + +Headless rendering +================== + +.. automodule:: pyabc2.sheet + +.. autosummary:: + :toctree: api/ + + svg + svg_to + build diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 6cbf737..5069d1f 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -1,4 +1,7 @@ -"""Render ABC notation to SVG sheet music using abcjs in the background.""" +"""Render ABC notation to SVG sheet music using `abcjs `__ in the background. + +See examples in :doc:`/examples/widget`. +""" import datetime import os @@ -12,6 +15,9 @@ def build(): + """Download abcjs and build our interface. + (:func:`svg` should call this automatically as needed.) + """ try: from nodejs_wheel import npm except ImportError as e: diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index b43328f..8769a21 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -1,5 +1,7 @@ """ -abcjs widget for Jupyter and more. +`abcjs `__ widget for Jupyter and more. + +See examples in :doc:`/examples/widget`. """ from pathlib import Path From 6df663756a20ee67c1a46bbb4101489e728a8811 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:10:29 -0600 Subject: [PATCH 37/64] Use tabs for the env var setting example --- docs/dev.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/dev.md b/docs/dev.md index 46eb4d4..0747737 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -48,9 +48,13 @@ You can now make a branch on your fork, work on the code, push your branch to Gi Enable hot reloading by setting environment variable `ANYWIDGET_HMR` to `1` before starting Jupyter Lab. +````{tab} Bash ```bash export ANYWIDGET_HMR=1 ``` +```` +````{tab} PowerShell ```powershell $env:ANYWIDGET_HMR = "1" ``` +```` From 28a78b7f06f91e6c44f936178747e7398aa62468 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:31:37 -0600 Subject: [PATCH 38/64] Please mypy --- pyabc2/sheet/__init__.py | 6 +++--- pyabc2/widget/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 5069d1f..660f3ef 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -86,7 +86,7 @@ def svg( text=True, ) if cp.returncode != 0: - info = indent(cp.stderr, "| ", lambda _: True) + info = indent(cp.stderr, "| ", lambda _: True) # type: ignore[arg-type] raise RuntimeError(f"Failed to render sheet music:\n{info}") assert isinstance(cp.stdout, str) @@ -158,5 +158,5 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: f.write(svg_str) for fmt in ["png", "PDF"]: - with open(f"test.{fmt}", "wb") as f: - f.write(svg_to(svg_str, fmt, write_to="asdf")) + with open(f"test.{fmt}", "wb") as f_: + f_.write(svg_to(svg_str, fmt, write_to="asdf")) diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index 8769a21..899f53d 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -26,7 +26,7 @@ class ABCJSWidget(anywidget.AnyWidget): abc = traitlets.Unicode("").tag(sync=True) # Output - svgs = traitlets.List(traitlets.Unicode, []).tag(sync=True) + svgs = traitlets.List(traitlets.Unicode(), []).tag(sync=True) # Options debug_box = traitlets.Bool(False).tag(sync=True) From 678596acb3ecfb406e567c17efe91b10da3c0601 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:34:18 -0600 Subject: [PATCH 39/64] widget and sheet deps needed for example nbs --- .readthedocs.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index bf9938c..9be6a8b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -12,6 +12,8 @@ python: extra_requirements: - doc - sources + - widget + - sheet sphinx: configuration: docs/conf.py From a4b16e214b44e729ba5380baa2e3e1130a6d747f Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:35:47 -0600 Subject: [PATCH 40/64] Fix ABC img example format --- docs/examples/widget.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/widget.ipynb b/docs/examples/widget.ipynb index 8e73a33..1fd2d7f 100644 --- a/docs/examples/widget.ipynb +++ b/docs/examples/widget.ipynb @@ -309,7 +309,7 @@ "outputs": [], "source": [ "try:\n", - " display(Image(data=svg_to(s, \"jpg\")))\n", + " display(Image(data=svg_to(s, \"png\")))\n", "except OSError:\n", " print(\"failed to load cairo\")" ] From adfaf72c63c483037be1a52d5846306ace7c9e9c Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:41:42 -0600 Subject: [PATCH 41/64] Build the node package before docs build --- .readthedocs.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 9be6a8b..b174027 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,6 +4,8 @@ build: os: ubuntu-24.04 tools: python: "3.11" + pre_build: + - python -c "from pyabc2.sheet import build; build()" python: install: From f0326fcc43b3800c906945c6b5a760cdd0bb0345 Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:42:07 -0600 Subject: [PATCH 42/64] Use Python 3.14 ubuntu-latest on RTD --- .readthedocs.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index b174027..fa89876 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,9 +1,9 @@ version: 2 build: - os: ubuntu-24.04 + os: ubuntu-lts-latest tools: - python: "3.11" + python: "3.14" pre_build: - python -c "from pyabc2.sheet import build; build()" From 42bf8e3b47ebfbeda8429182a96257565711cefb Mon Sep 17 00:00:00 2001 From: zmoon Date: Thu, 15 Jan 2026 20:44:22 -0600 Subject: [PATCH 43/64] Fix build step --- .readthedocs.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index fa89876..03384b8 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,8 +4,9 @@ build: os: ubuntu-lts-latest tools: python: "3.14" - pre_build: - - python -c "from pyabc2.sheet import build; build()" + jobs: + pre_build: + - python -c "from pyabc2.sheet import build; build()" python: install: From 2f39cfcb92c3e4cbd3cf8b2ab383ec2a65b9f697 Mon Sep 17 00:00:00 2001 From: Zachary Moon Date: Thu, 15 Jan 2026 22:18:50 -0600 Subject: [PATCH 44/64] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pyabc2/html.py | 2 +- pyabc2/sheet/__init__.py | 11 +++++++---- pyabc2/sheet/cli.cjs | 4 ++-- pyabc2/widget/__init__.py | 2 +- pyabc2/widget/index.js | 6 +++--- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/pyabc2/html.py b/pyabc2/html.py index 1f448c5..824b5be 100644 --- a/pyabc2/html.py +++ b/pyabc2/html.py @@ -60,7 +60,7 @@ def html( abc_indented = "\n".join(" " * ind + line.lstrip() for line in abc.strip().splitlines()) ind = 6 - s_param_lines = "\n".join(" " * (ind + 2) + f"{k}: {v!r}," for k, v in sorted(params.items())) + s_param_lines = ",\n".join(" " * (ind + 2) + f"{k}: {v!r}" for k, v in sorted(params.items())) s_params = r"{" + "\n" + s_param_lines + "\n" + " " * ind + "}" s = FULLPAGE_TPL.format( diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 660f3ef..878fece 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -36,10 +36,13 @@ def _maybe_build(): now = datetime.datetime.now().timestamp() package_lock = HERE / "package-lock.json" node_modules = HERE / "node_modules" + package_lock_exists = package_lock.exists() + node_modules_exists = node_modules.exists() + package_lock_old = package_lock_exists and now - package_lock.stat().st_mtime > 7 * 24 * 3600 if ( - not package_lock.exists() - or not node_modules.exists() - or (package_lock.exists() and now - package_lock.stat().st_mtime > 7 * 24 * 3600) + not package_lock_exists + or not node_modules_exists + or package_lock_old or ALWAYS_BUILD ): build() @@ -104,7 +107,7 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: fmt The format to convert to, e.g., 'png', 'pdf', 'ps', 'svg'. **kwargs - Passed to the `cairgosvg function `__. + Passed to the `cairosvg function `__. Options include: - ``scale`` diff --git a/pyabc2/sheet/cli.cjs b/pyabc2/sheet/cli.cjs index e27baa5..759e69c 100644 --- a/pyabc2/sheet/cli.cjs +++ b/pyabc2/sheet/cli.cjs @@ -11,8 +11,8 @@ process.argv.slice(2).forEach(arg => { const key = match[1]; let value = match[2]; - // Convert string value to number - if (!isNaN(value)) { + // Convert string value to number, but avoid treating empty strings as numbers + if (value.trim() !== '' && !isNaN(value)) { value = parseFloat(value); } diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index 899f53d..c470e10 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -53,7 +53,7 @@ def interactive(abc: str = "", **kwargs) -> "ipywidgets.Widget": style={"description_width": "125px"}, ) input_box = ipw.Textarea( - value=None, + value=abc, placeholder="Type something", layout={"width": "500px", "height": "5rem"}, ) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index e785e25..5c2222f 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -34,13 +34,13 @@ function initialize({ model }) { function getRandomString() { - // from a-z and 0-1 + // from a-z and 0-9 return Math.random().toString(36).substring(2, 9); } function render({ model, el }) { - console.log("render") + console.log("render"); let abc = () => model.get('abc'); @@ -163,7 +163,7 @@ function render({ model, el }) { }; // Remove callback - model.off("change:abc", on_change); + model.off("change", on_change); }; } From 0782cbf6f163967ac98920d807083b1a81e28a96 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 04:20:31 +0000 Subject: [PATCH 45/64] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyabc2/sheet/__init__.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 878fece..d0ae3c6 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -39,12 +39,7 @@ def _maybe_build(): package_lock_exists = package_lock.exists() node_modules_exists = node_modules.exists() package_lock_old = package_lock_exists and now - package_lock.stat().st_mtime > 7 * 24 * 3600 - if ( - not package_lock_exists - or not node_modules_exists - or package_lock_old - or ALWAYS_BUILD - ): + if not package_lock_exists or not node_modules_exists or package_lock_old or ALWAYS_BUILD: build() From 1a10aa71cde6a8d957207d216a6e87a4dd8e3ad3 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 21:30:59 -0600 Subject: [PATCH 46/64] Add warning for Tune HTML display deps --- pyabc2/parse.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pyabc2/parse.py b/pyabc2/parse.py index 35fec2c..9105410 100644 --- a/pyabc2/parse.py +++ b/pyabc2/parse.py @@ -3,6 +3,7 @@ """ import re +import warnings from collections.abc import Iterator from typing import NamedTuple @@ -350,11 +351,16 @@ def __hash__(self): return hash(self.abc) def _repr_html_(self): # pragma: no cover - from IPython.display import display - - from .widget import ABCJSWidget - - display(ABCJSWidget(abc=self.abc)) + try: + from IPython.display import display + + from .widget import ABCJSWidget + except ImportError: + warnings.warn( + "The 'widget' extra is required for HTML representation of tunes via abcjs." + ) + else: + display(ABCJSWidget(abc=self.abc)) def print_measures(self, n: int | None = None, *, note_format: str = "ABC"): """Print measures to check parsing. From 10cc7f42d1ec1f816a85a2f50b8d1a6cd645325d Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 21:37:38 -0600 Subject: [PATCH 47/64] When viewing HTML, keep session open, clean up tmp file on exit --- pyabc2/html.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pyabc2/html.py b/pyabc2/html.py index 824b5be..2938476 100644 --- a/pyabc2/html.py +++ b/pyabc2/html.py @@ -78,6 +78,8 @@ def open_html( ) -> None: """Generate an HTML page to render ABC with abcjs and open it in a new tab with the default web browser.""" + import atexit + import os from tempfile import NamedTemporaryFile from webbrowser import open_new_tab @@ -86,8 +88,23 @@ def open_html( f.write(s) path = f.name + def cleanup(): + try: + os.remove(path) + except Exception: + pass + + atexit.register(cleanup) + open_new_tab(path) + while True: + try: + input("Press Enter or Ctrl+C to close the temporary file and exit...") + break + except KeyboardInterrupt: + break + if __name__ == "__main__": # pragma: no cover abc = """\ From ebb06644f97dd5e4474de9dbeb3403d96a9c15e8 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 21:49:06 -0600 Subject: [PATCH 48/64] Move visual test of warning to test suite --- pyabc2/sheet/__init__.py | 4 ++-- tests/test_abcjs_nodejs.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/test_abcjs_nodejs.py diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index d0ae3c6..d241a31 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -156,5 +156,5 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: f.write(svg_str) for fmt in ["png", "PDF"]: - with open(f"test.{fmt}", "wb") as f_: - f_.write(svg_to(svg_str, fmt, write_to="asdf")) + with open(f"test.{fmt}", "wb") as fb: + fb.write(svg_to(svg_str, fmt)) diff --git a/tests/test_abcjs_nodejs.py b/tests/test_abcjs_nodejs.py new file mode 100644 index 0000000..c7acc42 --- /dev/null +++ b/tests/test_abcjs_nodejs.py @@ -0,0 +1,35 @@ +import pytest + +from pyabc2.sheet import svg, svg_to + +HAVE_CAIROSVG = True +try: + import cairosvg # noqa: F401 +except (ImportError, OSError): + # OSError raised if cairosvg is installed but fails to find the cairo library + HAVE_CAIROSVG = False + + +@pytest.mark.skipif(not HAVE_CAIROSVG, reason="cairosvg is not available") +def test_svg_to_ignored_args(): + abc = """\ + ABCD + """ + # Note: the indent is ignored by abcjs + + svg_str = svg( + abc, + scale=3, + staff_width=200, + foregroundColor="blue", + lineThickness=0.2, + ) + assert isinstance(svg_str, str) + + with pytest.warns( + UserWarning, + match="Keyword 'write_to' is not supported and will be ignored.", + ): + png_bytes = svg_to(svg_str, "PNG", write_to="asdf") + + assert isinstance(png_bytes, bytes) From a5c58fde1452f611752aa6040b921ee8ae51c19c Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 22:01:57 -0600 Subject: [PATCH 49/64] Prevent ABC debug view from interpreting HTML --- pyabc2/widget/index.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyabc2/widget/index.js b/pyabc2/widget/index.js index 5c2222f..7ec5050 100644 --- a/pyabc2/widget/index.js +++ b/pyabc2/widget/index.js @@ -94,10 +94,11 @@ function render({ model, el }) { music.innerHTML = ''; }; + head.innerHTML = ''; if (showDebugInput() && !hide()) { - head.innerHTML = `${abc()}`; - } else { - head.innerHTML = ''; + let code = document.createElement('code'); + code.textContent = abc(); + head.appendChild(code); } if (showDebugBox() && !hide()) { music.classList.add('debug'); From a9179a03d55391b300bc86b5ff7d7bf3f836e5e7 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 22:21:56 -0600 Subject: [PATCH 50/64] Add widget attr helps these show up as description in the docs too --- pyabc2/widget/__init__.py | 70 +++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index c470e10..ddd4a2e 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -17,28 +17,72 @@ class ABCJSWidget(anywidget.AnyWidget): - """Display SVG sheet music rendered from ABC notation by abcjs.""" + """Display SVG sheet music rendered from ABC notation by abcjs. + + Examples + -------- + >>> from pyabc2.widget import ABCJSWidget + >>> w = ABCJSWidget(abc="ABCD", staff_width=250) + """ _esm = HERE / "index.js" _css = HERE / "index.css" # Input - abc = traitlets.Unicode("").tag(sync=True) + abc = traitlets.Unicode( + "", + help="The ABC notation to render.", + ).tag(sync=True) # Output - svgs = traitlets.List(traitlets.Unicode(), []).tag(sync=True) + svgs = traitlets.List( + traitlets.Unicode(), + [], + help="List of stored rendered SVG strings.", + ).tag(sync=True) # Options - debug_box = traitlets.Bool(False).tag(sync=True) - debug_grid = traitlets.Bool(False).tag(sync=True) - debug_input = traitlets.Bool(False).tag(sync=True) - foreground = traitlets.Unicode(None, allow_none=True).tag(sync=True) - hide = traitlets.Bool(False).tag(sync=True) - line_thickness_increase = traitlets.Float(0.0).tag(sync=True) - logo = traitlets.Bool(False).tag(sync=True) - scale = traitlets.Float(1.0).tag(sync=True) - staff_width = traitlets.Integer(740).tag(sync=True) - transpose = traitlets.Integer(0).tag(sync=True) + debug_box = traitlets.Bool( + False, + help="Add box outline around the widget.", + ).tag(sync=True) + debug_grid = traitlets.Bool( + False, + help="Show grid lines.", + ).tag(sync=True) + debug_input = traitlets.Bool( + False, + help="Show the ABC input as part of the widget.", + ).tag(sync=True) + foreground = traitlets.Unicode( + None, + allow_none=True, + help="Color of the music rendering.", + ).tag(sync=True) + hide = traitlets.Bool( + False, + help="Hide the widget.", + ).tag(sync=True) + line_thickness_increase = traitlets.Float( + 0.0, + help="Increase in line thickness.", + ).tag(sync=True) + logo = traitlets.Bool( + False, + help="Show the abcjs logo at the top left.", + ).tag(sync=True) + scale = traitlets.Float( + 1.0, + help="Scaling factor for the music rendering.", + ).tag(sync=True) + staff_width = traitlets.Integer( + 740, + help="Width of the staff in pixels.", + ).tag(sync=True) + transpose = traitlets.Integer( + 0, + help="Visual transpose in half steps.", + ).tag(sync=True) def interactive(abc: str = "", **kwargs) -> "ipywidgets.Widget": From 53c34f78f452f1a290386895000175c7be7eb6ac Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 22:24:11 -0600 Subject: [PATCH 51/64] doc --- pyabc2/html.py | 4 ++++ pyabc2/sheet/__init__.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/pyabc2/html.py b/pyabc2/html.py index 2938476..63d8662 100644 --- a/pyabc2/html.py +++ b/pyabc2/html.py @@ -45,6 +45,10 @@ def html( ---------- abc The ABC notation to render. + scale + Scaling factor for the music rendering. + staff_width + Width of the staff in pixels. **kwargs Additional abcjs options that haven't been explicitly defined here in the signature. diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index d241a31..80a6f1c 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -57,6 +57,10 @@ def svg( ---------- abc The ABC notation to render. + scale + Scaling factor for the music rendering. + staff_width + Width of the staff in pixels. **kwargs Additional abcjs options that haven't been explicitly defined here in the signature. From f0711e820101e861eaed95fef4365415741324f8 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 22:37:44 -0600 Subject: [PATCH 52/64] Sort of test the html module --- pyabc2/widget/__init__.py | 2 +- tests/test_html.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/test_html.py diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index ddd4a2e..07c490c 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -85,7 +85,7 @@ class ABCJSWidget(anywidget.AnyWidget): ).tag(sync=True) -def interactive(abc: str = "", **kwargs) -> "ipywidgets.Widget": +def interactive(abc: str = "", **kwargs) -> "ipywidgets.Widget": # pragma: no cover """Return a Jupyter widget for interactive use, using ipywidgets.""" import ipywidgets as ipw diff --git a/tests/test_html.py b/tests/test_html.py new file mode 100644 index 0000000..bec7257 --- /dev/null +++ b/tests/test_html.py @@ -0,0 +1,30 @@ +from pathlib import Path +from tempfile import tempdir + +from pyabc2.html import html, open_html + + +def test_html_basic(): + abc = "ABCD" + html_str = html(abc) + assert abc in html_str + + +def test_open_html(monkeypatch): + called = {} + + def mock_open_new_tab(url: str) -> None: + called["url"] = url + + def mock_input(arg: str) -> str: + return "\n" + + monkeypatch.setattr("webbrowser.open_new_tab", mock_open_new_tab) + monkeypatch.setattr("builtins.input", mock_input) + + abc = "ABCD" + open_html(abc) + + url = called["url"] + assert url.startswith(tempdir) + assert Path(url).exists(), "haven't exited yet" From 03b7c1bdf1e581b967a674959753925c3d3c628a Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 22:46:16 -0600 Subject: [PATCH 53/64] Don't cover npm build errors --- pyabc2/sheet/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 80a6f1c..a2c5f2b 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -20,7 +20,7 @@ def build(): """ try: from nodejs_wheel import npm - except ImportError as e: + except ImportError as e: # pragma: no cover raise RuntimeError( "The 'nodejs-wheel-binaries' package is required " "to render sheet music in the background with abcjs via Node.js. " @@ -28,7 +28,7 @@ def build(): ) from e rc = npm(["install", "--prefix", HERE.as_posix()]) - if rc != 0: + if rc != 0: # pragma: no cover raise RuntimeError("Build failed") From 9d007eb3fbca5972584ef06fcf3fc1e4bdd7d3ac Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 22:51:14 -0600 Subject: [PATCH 54/64] Test some `svg_to` errors --- pyabc2/sheet/__init__.py | 3 ++- tests/test_abcjs_nodejs.py | 29 +++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index a2c5f2b..71b1db5 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -116,7 +116,8 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: """ try: import cairosvg - except ImportError as e: + except (ImportError, OSError) as e: + # OSError raised if cairosvg is installed but fails to find the cairo library raise RuntimeError( "The 'cairosvg' package is required to convert SVG to other formats." ) from e diff --git a/tests/test_abcjs_nodejs.py b/tests/test_abcjs_nodejs.py index c7acc42..a789994 100644 --- a/tests/test_abcjs_nodejs.py +++ b/tests/test_abcjs_nodejs.py @@ -10,8 +10,7 @@ HAVE_CAIROSVG = False -@pytest.mark.skipif(not HAVE_CAIROSVG, reason="cairosvg is not available") -def test_svg_to_ignored_args(): +def test_svg_to(): abc = """\ ABCD """ @@ -26,10 +25,24 @@ def test_svg_to_ignored_args(): ) assert isinstance(svg_str, str) - with pytest.warns( - UserWarning, - match="Keyword 'write_to' is not supported and will be ignored.", - ): - png_bytes = svg_to(svg_str, "PNG", write_to="asdf") + if HAVE_CAIROSVG: + with pytest.warns( + UserWarning, + match="Keyword 'write_to' is not supported and will be ignored.", + ): + png_bytes = svg_to(svg_str, "PNG", write_to="asdf") - assert isinstance(png_bytes, bytes) + assert isinstance(png_bytes, bytes) + + with pytest.raises( + ValueError, + match="Unsupported format: 'asdf'", + ): + _ = svg_to(svg_str, "asdf") + + else: + with pytest.raises( + RuntimeError, + match="The 'cairosvg' package is required to convert SVG to other formats.", + ): + _ = svg_to(svg_str, "PNG") From a165f5609a795a5468821d6551106b1797023ae1 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 22:52:36 -0600 Subject: [PATCH 55/64] Test in_jupyter --- tests/test_pyabc2.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_pyabc2.py b/tests/test_pyabc2.py index a925eb0..096e35c 100644 --- a/tests/test_pyabc2.py +++ b/tests/test_pyabc2.py @@ -7,6 +7,7 @@ pyabc2_metadata = metadata("pyabc2") import pyabc2 +from pyabc2._util import in_jupyter def test_version(): @@ -18,3 +19,7 @@ def test_short_description_consistency(): module_descrip = pyabc2.__doc__.strip().split("\n")[0] assert module_descrip == pyabc2_metadata["summary"] + + +def test_in_jupyter(): + assert not in_jupyter() From d89f1199a7b45160534234464a0d5fa80bf478e8 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:01:43 -0600 Subject: [PATCH 56/64] cov --- pyabc2/_util.py | 2 +- pyabc2/html.py | 2 +- pyabc2/sheet/__init__.py | 4 ++-- tests/test_html.py | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyabc2/_util.py b/pyabc2/_util.py index 96060ee..e8222c3 100644 --- a/pyabc2/_util.py +++ b/pyabc2/_util.py @@ -23,7 +23,7 @@ def in_jupyter() -> bool: # Reference: https://stackoverflow.com/a/47428575 try: from IPython.core import getipython # type: ignore - except (ImportError, ModuleNotFoundError): + except (ImportError, ModuleNotFoundError): # pragma: no cover return False # diff --git a/pyabc2/html.py b/pyabc2/html.py index 63d8662..ef6b10e 100644 --- a/pyabc2/html.py +++ b/pyabc2/html.py @@ -106,7 +106,7 @@ def cleanup(): try: input("Press Enter or Ctrl+C to close the temporary file and exit...") break - except KeyboardInterrupt: + except KeyboardInterrupt: # pragma: no cover break diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 71b1db5..e01e475 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -87,7 +87,7 @@ def svg( capture_output=True, text=True, ) - if cp.returncode != 0: + if cp.returncode != 0: # pragma: no cover info = indent(cp.stderr, "| ", lambda _: True) # type: ignore[arg-type] raise RuntimeError(f"Failed to render sheet music:\n{info}") @@ -116,7 +116,7 @@ def svg_to(svg: str, fmt: str, **kwargs) -> bytes: """ try: import cairosvg - except (ImportError, OSError) as e: + except (ImportError, OSError) as e: # pragma: no cover # OSError raised if cairosvg is installed but fails to find the cairo library raise RuntimeError( "The 'cairosvg' package is required to convert SVG to other formats." diff --git a/tests/test_html.py b/tests/test_html.py index bec7257..00159cc 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,3 +1,4 @@ +import atexit from pathlib import Path from tempfile import tempdir @@ -28,3 +29,6 @@ def mock_input(arg: str) -> str: url = called["url"] assert url.startswith(tempdir) assert Path(url).exists(), "haven't exited yet" + + atexit._run_exitfuncs() + assert not Path(url).exists(), "should be cleaned up after exit" From 870ea2941287ff61137a615ce7c499e1a3a18a49 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:04:12 -0600 Subject: [PATCH 57/64] Try to debug why npm build running again in SVG example --- pyabc2/sheet/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index e01e475..169a114 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -39,6 +39,15 @@ def _maybe_build(): package_lock_exists = package_lock.exists() node_modules_exists = node_modules.exists() package_lock_old = package_lock_exists and now - package_lock.stat().st_mtime > 7 * 24 * 3600 + print("package_lock_exists:", package_lock_exists) + print("node_modules_exists:", node_modules_exists) + print("package_lock_old:", package_lock_old) + print( + "now:", + now, + "package_lock mtime:", + package_lock.stat().st_mtime if package_lock_exists else "N/A", + ) if not package_lock_exists or not node_modules_exists or package_lock_old or ALWAYS_BUILD: build() From f98295e30d6abacd0384447dd8b67caea6ed51f2 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:11:33 -0600 Subject: [PATCH 58/64] Revert "Try to debug why npm build running again in SVG example" This reverts commit 870ea2941287ff61137a615ce7c499e1a3a18a49. Seems like the npm build was not there in the docs build. Probably it built in the cloned repo pyabc2, not the installed one. --- pyabc2/sheet/__init__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pyabc2/sheet/__init__.py b/pyabc2/sheet/__init__.py index 169a114..e01e475 100644 --- a/pyabc2/sheet/__init__.py +++ b/pyabc2/sheet/__init__.py @@ -39,15 +39,6 @@ def _maybe_build(): package_lock_exists = package_lock.exists() node_modules_exists = node_modules.exists() package_lock_old = package_lock_exists and now - package_lock.stat().st_mtime > 7 * 24 * 3600 - print("package_lock_exists:", package_lock_exists) - print("node_modules_exists:", node_modules_exists) - print("package_lock_old:", package_lock_old) - print( - "now:", - now, - "package_lock mtime:", - package_lock.stat().st_mtime if package_lock_exists else "N/A", - ) if not package_lock_exists or not node_modules_exists or package_lock_old or ALWAYS_BUILD: build() From 98c9de9f99ac797111797e7810afec8484df5432 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:19:07 -0600 Subject: [PATCH 59/64] Demo build instead of trying to hide it --- .readthedocs.yaml | 3 --- docs/examples/widget.ipynb | 23 +++++++++++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 03384b8..8b387a5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,9 +4,6 @@ build: os: ubuntu-lts-latest tools: python: "3.14" - jobs: - pre_build: - - python -c "from pyabc2.sheet import build; build()" python: install: diff --git a/docs/examples/widget.ipynb b/docs/examples/widget.ipynb index 1fd2d7f..cfc988f 100644 --- a/docs/examples/widget.ipynb +++ b/docs/examples/widget.ipynb @@ -301,16 +301,35 @@ "SVG(s)" ] }, + { + "cell_type": "markdown", + "id": "26", + "metadata": {}, + "source": [ + "{func}`~pyabc2.sheet.build` is automatically called if needed.\n", + "But the next time we call {func}`~pyabc2.sheet.svg`, it will be ready to go:" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "SVG(svg(\"A2 B2 C2 D2 |\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", "metadata": {}, "outputs": [], "source": [ "try:\n", " display(Image(data=svg_to(s, \"png\")))\n", - "except OSError:\n", + "except RuntimeError:\n", " print(\"failed to load cairo\")" ] } From 5438e289e198da55b9f2aadfcfe855171dfddb10 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:21:02 -0600 Subject: [PATCH 60/64] cov --- pyabc2/html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyabc2/html.py b/pyabc2/html.py index ef6b10e..c5f3ddc 100644 --- a/pyabc2/html.py +++ b/pyabc2/html.py @@ -95,7 +95,7 @@ def open_html( def cleanup(): try: os.remove(path) - except Exception: + except Exception: # pragma: no cover pass atexit.register(cleanup) From 130189e434f53c16d06a6d2e667dd81f76072693 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:29:30 -0600 Subject: [PATCH 61/64] Use gettempdir in test --- tests/test_html.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_html.py b/tests/test_html.py index 00159cc..6c2e69d 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,9 +1,11 @@ import atexit from pathlib import Path -from tempfile import tempdir +from tempfile import gettempdir from pyabc2.html import html, open_html +TMP = gettempdir() + def test_html_basic(): abc = "ABCD" @@ -27,7 +29,7 @@ def mock_input(arg: str) -> str: open_html(abc) url = called["url"] - assert url.startswith(tempdir) + assert url.startswith(TMP) assert Path(url).exists(), "haven't exited yet" atexit._run_exitfuncs() From b6e223a7624b6d0a3fef55406c74d66a2bee1f37 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:30:45 -0600 Subject: [PATCH 62/64] typo --- pyabc2/sheet/render.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyabc2/sheet/render.js b/pyabc2/sheet/render.js index 467c32b..d13c868 100644 --- a/pyabc2/sheet/render.js +++ b/pyabc2/sheet/render.js @@ -14,7 +14,7 @@ import { JSDOM } from 'jsdom'; export function render(abc, params = {}) { const dom = new JSDOM(``); - // We make `document` globally accessibly since abcjs uses it to create the SVG + // We make `document` globally accessible since abcjs uses it to create the SVG global.document = dom.window.document; const XMLSerializer = dom.window.XMLSerializer; From 22df9ab6517e78525ddf5a72e0b6f549d5d62db8 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:32:45 -0600 Subject: [PATCH 63/64] loop not needed --- pyabc2/html.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pyabc2/html.py b/pyabc2/html.py index c5f3ddc..29e442a 100644 --- a/pyabc2/html.py +++ b/pyabc2/html.py @@ -102,12 +102,10 @@ def cleanup(): open_new_tab(path) - while True: - try: - input("Press Enter or Ctrl+C to close the temporary file and exit...") - break - except KeyboardInterrupt: # pragma: no cover - break + try: + input("Press Enter or Ctrl+C to close the temporary file and exit...") + except KeyboardInterrupt: # pragma: no cover + pass if __name__ == "__main__": # pragma: no cover From 9359305a0cb6be36aef79c12a8cee8bdde015034 Mon Sep 17 00:00:00 2001 From: zmoon Date: Fri, 16 Jan 2026 23:43:30 -0600 Subject: [PATCH 64/64] Don't override passed foreground for ipywidget --- docs/examples/widget.ipynb | 2 +- pyabc2/widget/__init__.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/examples/widget.ipynb b/docs/examples/widget.ipynb index cfc988f..ecb771a 100644 --- a/docs/examples/widget.ipynb +++ b/docs/examples/widget.ipynb @@ -213,7 +213,7 @@ "source": [ "abc = \"K: C\\nT: The best scale\\n\" + \"CDEF GABc | \" * 4\n", "\n", - "w = interactive(abc, foreground=\"#303030\", staff_width=600)\n", + "w = interactive(abc, foreground=\"#808080\", staff_width=600)\n", "w" ] }, diff --git a/pyabc2/widget/__init__.py b/pyabc2/widget/__init__.py index 07c490c..a953e11 100644 --- a/pyabc2/widget/__init__.py +++ b/pyabc2/widget/__init__.py @@ -89,8 +89,12 @@ def interactive(abc: str = "", **kwargs) -> "ipywidgets.Widget": # pragma: no c """Return a Jupyter widget for interactive use, using ipywidgets.""" import ipywidgets as ipw + if "foreground" not in kwargs: + # Explicitly set default foreground color to black + # so it shows up in the color picker + kwargs["foreground"] = "black" + w = ABCJSWidget(abc=abc, **kwargs) - w.foreground = "black" slider_kws = dict( layout={"width": "500px"},