Kaynağa Gözat

update from osmd 1.4.0: many improvements, see changelog. needs npm install. try deleting node_modules and package-lock.json

try deleting node_modules and package-lock.json before running npm install and npm start again after this update.
sschmidTU 3 yıl önce
ebeveyn
işleme
1d153ef688
40 değiştirilmiş dosya ile 3510 ekleme ve 187 silme
  1. 3 4
      .appveyor.yml
  2. 31 0
      .travis.yml
  3. 21 12
      demo/index.js
  4. 5 1
      karma.conf.js
  5. 59 59
      package.json
  6. 102 0
      src/KarmaWebpackPatch/lib/karma-webpack/preprocessor.js
  7. 34 0
      src/KarmaWebpackPatch/lib/webpack/plugin.js
  8. 3 0
      src/MusicalScore/Graphical/EngravingRules.ts
  9. 1 1
      src/MusicalScore/Graphical/MusicSheetDrawer.ts
  10. 4 4
      src/MusicalScore/Graphical/VexFlow/SvgVexFlowBackend.ts
  11. 2 0
      src/MusicalScore/Graphical/VexFlow/VexFlowConverter.ts
  12. 20 0
      src/MusicalScore/Graphical/VexFlow/VexFlowGraphicalNote.ts
  13. 4 1
      src/MusicalScore/Graphical/VexFlow/VexFlowMusicSheetCalculator.ts
  14. 55 3
      src/MusicalScore/ScoreIO/InstrumentReader.ts
  15. 25 18
      src/MusicalScore/ScoreIO/VoiceGenerator.ts
  16. 4 2
      src/MusicalScore/VoiceData/Tie.ts
  17. 1 1
      src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts
  18. 9 1
      src/VexFlowPatch/readme.txt
  19. 17 3
      src/VexFlowPatch/src/beam.js
  20. 73 48
      src/VexFlowPatch/src/stavenote.js
  21. 175 0
      src/VexFlowPatch/src/stem.js
  22. 692 0
      src/VexFlowPatch/src/svgcontext.js
  23. 1 1
      test/Common/FileIO/Xml_Test.ts
  24. 47 0
      test/MusicalScore/Graphical/VexFlow/VexFlowGraphicalNote_Test.ts
  25. 2 18
      test/MusicalScore/Graphical/VexFlow/VexFlowMeasure_Test.ts
  26. 1 1
      test/MusicalScore/ScoreCalculation/MusicSheetCalculator_Test.ts
  27. 1 1
      test/Util/DiffImages_Test_Experimental.ts
  28. 1 1
      test/Util/generateDiffImagesPuppeteerLocalhost.js
  29. 399 0
      test/Util/generateImages_browserless.mjs
  30. 163 0
      test/data/test_beam_svg_double.musicxml
  31. 704 0
      test/data/test_clef_end_measure.musicxml
  32. 125 0
      test/data/test_note_alignment_long_distance_notes.musicxml
  33. 179 0
      test/data/test_note_overlap_staggering_whole_eighths.musicxml
  34. 140 0
      test/data/test_note_overlap_staggering_whole_half.musicxml
  35. 150 0
      test/data/test_notehead_sharing_eighth_quarter.musicxml
  36. 246 0
      test/data/test_ties_missing_1097.musicxml
  37. 7 3
      webpack.common.js
  38. 1 1
      webpack.dev.js
  39. 2 2
      webpack.prod.js
  40. 1 1
      webpack.sourcemap.js

+ 3 - 4
.appveyor.yml

@@ -1,10 +1,9 @@
 image: Visual Studio 2017
 environment:
-  timeout: 10000
+  timeout: 20000
   matrix:
-    # - nodejs_version: "8" 
-    # - nodejs_version: "10"
-    - nodejs_version: "12"
+    # - nodejs_version: "12"
+    - nodejs_version: "16"
 platform:
   # - x86
   - x64

+ 31 - 0
.travis.yml

@@ -0,0 +1,31 @@
+sudo: required
+dist: trusty
+language: node_js
+node_js:
+# - '10' # fails on Travis since upgrading to Vexflow 1.2.90 (still passes on AppVeyor), for Mxl_Test. Node 12 works.
+- '12'
+env:
+  - timeout=15000
+notifications:
+  email: false
+  slack:
+    secure: ZLC7oJ5BcdVfLX+R7Bg6NUaFrrOG5heX9t8lpCZAWtHG4Uag/z5hCAtr/pfdaoEZ4AFJ7SS0yubE3EltwoXdx/zeGlF7gV5JxjDtDyNpkqFa38XTSorP/0FYjaahecFnxUYG2oNQWTcnyeE6BMak+RQ4+ciLC1dQrzC84FNE4R28tV5SVwgM+O1JAFg67Z2Xu497tNuLG6aptyRAov6G0mo9e1oLW4apuiV4CnV+p2nMYbLEyHT5TJiQ8/c7ar7jM7Ia8bL6WGHGjOmEmy71DyWWQXBlE+RSS8uBRlF7BvGX7/fleCUa4jE5ieP+IKCENfa+9+SCE6i8YEAc8Wyfqdl/f5A7NqPDNYxWxU1w8iSM4/FJn6hJKJ3vnogAdQUlPtNYssMio2U06bkvtZ+hu961f6qcGaR10fcX8EHi1UwFDHQ+9uha+9U5vF/+EQHXAG5WGSKrpbH3CFypdJ8g3U1eW5qJn76W9Um4COSj26KI+pBTD9gZwaZCmDas0g2bECIClUKK4y1utsYf/KiJcJaIOEE+QvFNyhuXwdAmTFi8OZ784yrbXmpQZqol2ckgfvWNQZnwqY8h3A8RDhXxvbv6UbNOfE8p/BgJCRaSZAkaqU7b9+D0kSaNIWVPbPad3+Plgkg/gvyC07l8GR5+9tMysz669VQXUs2vzIMIzfA=
+deploy:
+  skip_cleanup: true
+  provider: npm
+  email: sebastian@haas.tech
+  api_key:
+    secure: eXeFmjyHP0PRxcydQIqORVobbKY5YT4SAjDsfp70fB0SlFt1brEwPW6Im5cjr32Zky7gSvVqs++mxyultyjxTHT7q3KYXbSsY6CHyRcV0FeFFB8tS0m5HX/bbCcz94//vCuKXPSajn/jcxQUUvsykhwe0abJt+5D8Nl2h4LOQW/5/ItOPvOjVKAaud4I1gVyYJPGFpYvuyRBswYt+JBCoMtRF/nr4qF0Q3PWiifiNpovIm80Zx4yNaALWjf7zJf9GmePBebTboPykXMHEw2ROC805Gao4iX+a9psc340z2LWaWz9Qugb0pa3zL7d2TY0WJLlqXpnZHMWXn+rlDazT5ysrp3mCLdEiDFkVTSbRioBx5QCmDPcWQW9NHxP3XheUBf+cv+1LUUMvYp6tEU9yQWrAl6sk0h8uEi0YSNE27nucXAZyDaavzwIlM//r7oQBpaKXE6NQc0pf2Dsv6fSwwZCokW8YuH42gtr6KTj5hoisaVo/nYRCzdL3iL69sPY6Kgjq47kknobEEgq9jitAEYsedaSADc/s8hzd4gidtfAMj8aTP1ZjDhDMMquUTx+Ksht4dStOZ72ULOIUew8r/j51QPvXVdwJS8bPjz7BEbUqLZxTLRqAUD0HzM7vaM+ynd83mrVruWurm624H2uZm57/yJvpaVnjKEbnLiBG8E=
+  on:
+    tags: true
+    branch: master
+    repo: opensheetmusicdisplay/opensheetmusicdisplay
+after_deploy:
+- openssl aes-256-cbc -K $encrypted_170846311824_key -iv $encrypted_170846311824_iv -in bin/gh_pages_deploy_key.enc -out bin/gh_pages_deploy_key -d
+- eval "$(ssh-agent -s)"
+- chmod 600 bin/gh_pages_deploy_key
+- ssh-add bin/gh_pages_deploy_key
+- git config --global user.email "travis@opensheetmusicdisplay.org"
+- git config --global user.name "travis"
+- chmod +x ./bin/publish_gh_page.sh
+- ./bin/publish_gh_page.sh

+ 21 - 12
demo/index.js

@@ -1,8 +1,8 @@
 import { OpenSheetMusicDisplay } from '../src/OpenSheetMusicDisplay/OpenSheetMusicDisplay';
 import { BackendType } from '../src/OpenSheetMusicDisplay/OSMDOptions';
 import { PlaybackManager, LinearTimingSource, BasicAudioPlayer, ControlPanel } from '../src/Playback';
-import * as jsPDF  from '../node_modules/jspdf-yworks/dist/jspdf.min';
-import * as svg2pdf from '../node_modules/svg2pdf.js/dist/svg2pdf.min';
+import * as jsPDF  from '../node_modules/jspdf/dist/jspdf.es.min';
+import * as svg2pdf from '../node_modules/svg2pdf.js/dist/svg2pdf.umd.min';
 import { TransposeCalculator } from '../src/Plugins/Transpose/TransposeCalculator';
 
 /*jslint browser:true */
@@ -875,7 +875,7 @@ import { TransposeCalculator } from '../src/Plugins/Transpose/TransposeCalculato
      * Creates a Pdf of the currently rendered MusicXML
      * @param pdfName if no name is given, the composer and title of the piece will be used
      */
-    function createPdf(pdfName) {
+    async function createPdf(pdfName) {
         if (openSheetMusicDisplay.backendType !== BackendType.SVG) {
             console.log("[OSMD] createPdf(): Warning: createPDF is only supported for SVG background for now, not for Canvas." +
                 " Please use osmd.setOptions({backendType: SVG}).");
@@ -901,20 +901,29 @@ import { TransposeCalculator } from '../src/Plugins/Transpose/TransposeCalculato
 
         const orientation = pageHeight > pageWidth ? "p" : "l";
         // create a new jsPDF instance
-        const pdf = new jsPDF(orientation, "mm", [pageWidth, pageHeight]);
-        const scale = pageWidth / svgElement.clientWidth;
+        const pdf = new jsPDF.jsPDF({
+            orientation: orientation,
+            unit: "mm",
+            format: [pageWidth, pageHeight]
+        });
+        //const scale = pageWidth / svgElement.clientWidth;
         for (let idx = 0, len = backends.length; idx < len; ++idx) {
             if (idx > 0) {
                 pdf.addPage();
             }
             svgElement = backends[idx].getSvgElement();
-
-            // render the svg element
-            svg2pdf(svgElement, pdf, {
-                scale: scale,
-                xOffset: 0,
-                yOffset: 0
-            });
+            
+            if (!pdf.svg && !svg2pdf) { // this line also serves to make the svg2pdf not unused, though it's still necessary
+                // we need svg2pdf to have pdf.svg defined
+                console.log("svg2pdf missing, necessary for jspdf.svg().");
+                return;
+            }
+            await pdf.svg(svgElement, {
+                x: 0,
+                y: 0,
+                width: pageWidth,
+                height: pageHeight,
+            })
         }
 
         pdf.save(pdfName); // save/download the created pdf

+ 5 - 1
karma.conf.js

@@ -25,6 +25,9 @@ module.exports = function (config) {
                 pattern: 'test/data/*.xml',
                 included: true
             }, {
+                pattern: 'test/data/*.musicxml',
+                included: true
+            }, {
                 pattern: 'test/data/*.mxl.base64',
                 included: true
             }, {
@@ -39,6 +42,7 @@ module.exports = function (config) {
         // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
         preprocessors: {
             'test/data/*.xml': ['xml2js'],
+            'test/data/*.musicxml': ['xml2js'],
             'test/data/*.mxl.base64': ['base64-to-js'],
             // add webpack as preprocessor
             'test/**/*.ts': ['webpack']
@@ -86,7 +90,7 @@ module.exports = function (config) {
         client: {
             captureConsole: true,
             mocha: {
-                timeout: process.env.timeout || 6000
+                timeout: process.env.timeout || 20000
             }
         },
 

+ 59 - 59
package.json

@@ -1,38 +1,40 @@
 {
   "name": "osmd-extended",
-  "version": "1.3.1",
+  "version": "1.4.0",
   "description": "Private / sponsor exclusive OSMD mirror/audio player.",
   "main": "build/opensheetmusicdisplay.min.js",
-  "typings": "build/dist/src/",
+  "types": "dist/src/OpenSheetMusicDisplay/index.d.ts",
   "scripts": {
     "docs": "typedoc --out ./build/docs --name OpenSheetMusicDisplay --module commonjs --target ES2017 --ignoreCompilerErrors --mode file ./src",
     "eslint": "eslint -c .eslintrc.js --ext .ts .",
     "lint": "npm run eslint",
     "test": "karma start --single-run --no-auto-watch",
     "test:watch": "karma start --no-single-run --auto-watch --browsers ChromeNoSecurity",
-    "prebuild": "ncp src/VexFlowPatch/src/ node_modules/vexflow/src/",
+    "prebuild": "npm-run-all prebuildVexflow prebuildKarma",
+    "prebuildVexflow": "ncp src/VexFlowPatch/src/ node_modules/vexflow/src/",
+    "prebuildKarma": "ncp src/KarmaWebpackPatch/lib/ node_modules/karma-webpack/lib/",
     "prepare": "npm run build",
     "build": "npm-run-all lint build:webpack",
     "build:doc": "cross-env STATIC_FILES_SUBFOLDER=sheets npm run build",
-    "build:webpack": "webpack --progress --colors --config webpack.prod.js",
-    "build:webpack-dev": "webpack --progress --colors --config webpack.dev.js",
-    "build:webpack-sourcemap": "webpack --progress --colors --config webpack.sourcemap.js",
-    "start": "webpack-dev-server --progress --colors --config webpack.dev.js",
-    "start:local": "webpack-dev-server --progress --colors --config webpack.local.js",
-    "generatePNG": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 0 0 allSmall --osmdtesting",
-    "generateSVG": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export/svg svg 0 0 allSmall --osmdtesting",
-    "generatePNG:debug": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 0 0 allSmall --debugosmdtesting",
-    "generatePNG:single": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 0 0 ^Beethoven",
-    "generatePNG:legacyslow": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data ./export 2560",
-    "generatePNG:paged": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 210 297 allSmall",
-    "generatePNG:paged:debug": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 210 297 all --debug 5000",
-    "generatePNG:paged:single": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 0 0 ^Beethoven",
-    "generate:current": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./visual_regression/current png 0 0 allSmall --osmdtesting",
-    "generate:current:debug": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./visual_regression/current png 0 0 allSmall --debugosmdtesting",
-    "generate:current:singletest": "node test/Util/generateImages_browserless.js ../../build ./test/data ./visual_regression/current png 0 0 ^Beethoven --osmdtestingsingle",
-    "generate:blessed": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./visual_regression/blessed png 0 0 allSmall --osmdtesting",
+    "build:webpack": "webpack --progress --config webpack.prod.js",
+    "build:webpack-dev": "webpack --progress --config webpack.dev.js",
+    "build:webpack-sourcemap": "webpack --progress --config webpack.sourcemap.js",
+    "start": "webpack-dev-server --progress --config webpack.dev.js",
+    "start:local": "webpack-dev-server --progress --config webpack.local.js",
+    "generatePNG": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 0 0 allSmall --osmdtesting",
+    "generateSVG": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export/svg svg 0 0 allSmall --osmdtesting",
+    "generatePNG:debug": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 0 0 allSmall --debugosmdtesting",
+    "generatePNG:single": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 0 0 ^Beethoven",
+    "generatePNG:legacyslow": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data ./export png 0 0 all",
+    "generatePNG:paged": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 210 297 allSmall",
+    "generatePNG:paged:debug": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 210 297 all --debug 5000",
+    "generatePNG:paged:single": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 0 0 ^Beethoven",
+    "generate:current": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 allSmall --osmdtesting",
+    "generate:current:debug": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 allSmall --debugosmdtesting",
+    "generate:current:singletest": "node test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 ^Beethoven --osmdtestingsingle",
+    "generate:blessed": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/blessed png 0 0 allSmall --osmdtesting",
     "test:visual": "bash ./test/Util/visual_regression.sh ./visual_regression",
-    "test:visual:build": "npm run prebuild && npm run build:webpack && npm run generate:current && npm run test:visual",
+    "test:visual:build": "npm-run-all prebuild build:webpack generate:current test:visual",
     "test:visual:singletest": "sh ./test/Util/visual_regression.sh ./visual_regression Beethoven",
     "fix-memory-limit": "cross-env NODE_OPTIONS=--max_old_space_size=4096"
   },
@@ -64,62 +66,60 @@
   },
   "homepage": "http://opensheetmusicdisplay.org",
   "dependencies": {
-    "@types/vexflow": "^3.0.0",
+    "@types/vexflow": "^1.2.37",
     "d-path-parser": "^1.0.0",
     "jszip": "3.7.1",
-    "loglevel": "^1.6.8",
+    "loglevel": "^1.8.0",
     "soundfont-player": "^0.12.0",
     "standardized-audio-context": "^25.1.5",
     "typescript-collections": "^1.3.3",
     "vexflow": "^1.2.93"
   },
   "devDependencies": {
-    "@types/chai": "^4.2.11",
-    "@types/mocha": "^7.0.2",
-    "@types/node": "^14.0.9",
+    "@types/chai": "^4.2.22",
+    "@types/mocha": "^9.0.0",
+    "@types/node": "^16.11.11",
     "@types/resize-observer-browser": "^0.1.5",
-    "@typescript-eslint/eslint-plugin": "^4.14.0",
-    "@typescript-eslint/parser": "^4.14.0",
+    "@typescript-eslint/eslint-plugin": "^5.5.0",
+    "@typescript-eslint/parser": "^5.5.0",
     "canvas": "^2.8.0",
-    "chai": "^4.1.0",
-    "clean-webpack-plugin": "^3.0.0",
-    "cross-blob": "^1.2.0",
-    "cross-env": "^7.0.2",
-    "cz-conventional-changelog": "^3.0.0",
-    "eslint": "^6.8.0",
-    "eslint-config-standard": "14.1.1",
-    "eslint-plugin-import": "^2.20.2",
-    "eslint-plugin-jsdoc": "^31.0.8",
+    "chai": "^4.3.4",
+    "clean-webpack-plugin": "^4.0.0",
+    "cross-blob": "^3.0.0",
+    "cross-env": "^7.0.3",
+    "cz-conventional-changelog": "^3.3.0",
+    "eslint": "^8.4.0",
+    "eslint-plugin-import": "^2.25.3",
+    "eslint-plugin-jsdoc": "^37.1.0",
     "eslint-plugin-no-null": "^1.0.2",
-    "eslint-plugin-node": "^11.0.0",
-    "eslint-plugin-promise": "^4.2.1",
-    "eslint-plugin-standard": "^4.0.0",
-    "html-webpack-plugin": "^4.3.0",
-    "jquery": "^3.5.1",
-    "jsdom": "^16.2.2",
-    "jspdf-yworks": "^2.1.1",
-    "karma": "^5.0.8",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-standard": "^4.1.0",
+    "html-webpack-plugin": "^5.5.0",
+    "jquery": "^3.6.0",
+    "jsdom": "^19.0.0",
+    "jspdf": "^2.4.0",
+    "karma": "^6.3.9",
     "karma-base64-to-js-preprocessor": "^0.1.0",
     "karma-chai": "^0.1.0",
     "karma-chrome-launcher": "^3.1.0",
-    "karma-firefox-launcher": "^1.0.0",
+    "karma-firefox-launcher": "^2.1.2",
     "karma-mocha": "^2.0.1",
-    "karma-mocha-reporter": "^2.0.4",
-    "karma-webpack": "^4.0.2",
+    "karma-mocha-reporter": "^2.2.5",
+    "karma-webpack": "^5.0.0",
     "karma-xml2js-preprocessor": "^0.1.0",
-    "mocha": "^7.0.1",
+    "mocha": "^9.1.3",
     "ncp": "^2.0.0",
-    "npm-run-all": "^4.1.2",
+    "npm-run-all": "^4.1.5",
     "pre-commit": "^1.2.2",
-    "svg2pdf.js": "^1.5.0",
-    "ts-loader": "^4.1.0",
-    "typedoc": "^0.17.3",
-    "typescript": "^3.9.5",
-    "webpack": "^4.43.0",
-    "webpack-cli": "^3.3.11",
-    "webpack-dev-server": "^3.11.2",
-    "webpack-merge": "^4.1.2",
-    "webpack-visualizer-plugin": "^0.1.11",
+    "svg2pdf.js": "^2.2.0",
+    "ts-loader": "^9.2.6",
+    "typedoc": "^0.22.10",
+    "typescript": "^4.5.2",
+    "webpack": "^5.64.4",
+    "webpack-cli": "^4.9.1",
+    "webpack-dev-server": "^4.7.0",
+    "webpack-merge": "^5.8.0",
+    "webpack-visualizer-plugin2": "^1.0.0",
     "html-loader": "^1.3.0",
     "json5-loader": "^4.0.0",
     "@material/layout-grid": "^7.0.0",

+ 102 - 0
src/KarmaWebpackPatch/lib/karma-webpack/preprocessor.js

@@ -0,0 +1,102 @@
+const path = require('path');
+
+const glob = require('glob');
+const minimatch = require('minimatch');
+
+const { ensureWebpackFrameworkSet } = require('../karma/validation');
+const { hash } = require('../utils/hash');
+
+const KW_Controller = require('./controller');
+
+function getPathKey(filePath, withExtension = false) {
+  const pathParts = path.parse(filePath);
+  const key = `${pathParts.name}.${hash(filePath)}`;
+  return withExtension ? `${key}${pathParts.ext}` : key;
+}
+
+function configToWebpackEntries(config) {
+  const filteredPreprocessorsPatterns = [];
+  const { preprocessors } = config;
+
+  let files = [];
+  config.files.forEach((fileEntry, i) => {
+    // forcefully disable karma watch as we use webpack watch only
+    config.files[i].watched = false;
+    files = [...files, ...glob.sync(fileEntry.pattern)];
+  });
+
+  Object.keys(preprocessors).forEach((pattern) => {
+    if (preprocessors[pattern].includes('webpack')) {
+      filteredPreprocessorsPatterns.push(pattern);
+    }
+  });
+
+  const filteredFiles = [];
+  files.forEach((filePath) => {
+    filteredPreprocessorsPatterns.forEach((pattern) => {
+      if (minimatch(filePath, pattern)) {
+        filteredFiles.push(filePath);
+      }
+    });
+  });
+
+  const webpackEntries = {};
+  filteredFiles.forEach((filePath) => {
+    webpackEntries[getPathKey(filePath)] = filePath;
+  });
+
+  return webpackEntries;
+}
+
+function KW_Preprocessor(config, emitter) {
+  const controller = new KW_Controller();
+  config.__karmaWebpackController = controller;
+  ensureWebpackFrameworkSet(config);
+
+  // one time setup
+  if (controller.isActive === false) {
+    controller.updateWebpackOptions({
+      entry: configToWebpackEntries(config),
+      watch: config.autoWatch,
+    });
+
+    if (config.webpack.entry) {
+      console.warn(`
+karma-webpack does not currently support custom entries, if this is something you need,
+consider opening an issue.
+ignoring attempt to set the entry option...
+      `);
+      delete config.webpack.entry;
+    }
+
+    controller.updateWebpackOptions(config.webpack);
+    controller.karmaEmitter = emitter;
+  }
+
+  const normalize = (file) => file.replace(/\\/g, '/');
+
+  const transformPath =
+    config.webpack.transformPath ||
+    ((filepath) => {
+      // force *.js files by default
+      const info = path.parse(filepath);
+      return `${path.join(info.dir, info.name)}.js`;
+    });
+
+  return function processFile(content, file, done) {
+    controller.bundle().then(() => {
+      file.path = normalize(file.path); // eslint-disable-line no-param-reassign
+
+      const transformedFilePath = transformPath(getPathKey(file.path, true));
+      const bundleContent = controller.bundlesContent[transformedFilePath];
+
+      file.path = transformedFilePath;
+
+      done(null, bundleContent);
+    });
+  };
+}
+
+KW_Preprocessor.$inject = ['config', 'emitter'];
+
+module.exports = KW_Preprocessor;

+ 34 - 0
src/KarmaWebpackPatch/lib/webpack/plugin.js

@@ -0,0 +1,34 @@
+const fs = require('fs');
+const path = require('path');
+
+class KW_WebpackPlugin {
+  constructor(options) {
+    this.karmaEmitter = options.karmaEmitter;
+    this.controller = options.controller;
+  }
+
+  apply(compiler) {
+    this.compiler = compiler;
+
+    // webpack bundles are finished
+    compiler.hooks.done.tap('KW_WebpackPlugin', (stats) => {
+      // read generated file content and store for karma preprocessor
+      this.controller.bundlesContent = {};
+      stats.toJson().assets.forEach((webpackFileObj) => {
+        const filePath = path.resolve(
+          compiler.options.output.path,
+          webpackFileObj.name
+        );
+        this.controller.bundlesContent[webpackFileObj.name] = fs.readFileSync(
+          filePath,
+          'utf-8'
+        );
+      });
+
+      // karma refresh
+      this.karmaEmitter.refreshFiles();
+    });
+  }
+}
+
+module.exports = KW_WebpackPlugin;

+ 3 - 0
src/MusicalScore/Graphical/EngravingRules.ts

@@ -238,6 +238,8 @@ export class EngravingRules {
     public MetronomeMarkXShift: number;
     public MetronomeMarkYShift: number;
     public SoftmaxFactorVexFlow: number;
+    /** Stagger (x-shift) whole notes that are the same note, but in different voices (show 2 instead of 1). */
+    public StaggerSameWholeNotes: boolean;
     public MaxInstructionsConstValue: number;
     public NoteDistances: number[] = [1.0, 1.0, 1.3, 1.6, 2.0, 2.5, 3.0, 4.0];
     public NoteDistancesScalingFactors: number[] = [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0];
@@ -591,6 +593,7 @@ export class EngravingRules {
         this.SoftmaxFactorVexFlow = 15; // only applies to Vexflow 3.x. 15 seems like the sweet spot. Vexflow default is 100.
         // if too high, score gets too big, especially half notes. with half note quarter quarter, the quarters get squeezed.
         // if too low, smaller notes aren't positioned correctly.
+        this.StaggerSameWholeNotes = true;
 
         // Render options (whether to render specific or invisible elements)
         this.AlignRests = AlignRestOption.Never; // 0 = false, 1 = true, 2 = auto

+ 1 - 1
src/MusicalScore/Graphical/MusicSheetDrawer.ts

@@ -50,7 +50,7 @@ export abstract class MusicSheetDrawer {
     public drawingParameters: DrawingParameters;
     public splitScreenLineColor: number;
     public midiPlaybackAvailable: boolean;
-    public drawableBoundingBoxElement: string = process.env.DRAW_BOUNDING_BOX_ELEMENT;
+    public drawableBoundingBoxElement: string = "None"; // process.env.DRAW_BOUNDING_BOX_ELEMENT;
 
     public skyLineVisible: boolean = false;
     public bottomLineVisible: boolean = false;

+ 4 - 4
src/MusicalScore/Graphical/VexFlow/SvgVexFlowBackend.ts

@@ -108,7 +108,7 @@ export class SvgVexFlowBackend extends VexFlowBackend {
                       heightInPixel: number, screenPosition: PointF2D,
                       color: string = undefined, fontFamily: string = undefined): Node {
         this.ctx.save();
-        const node: Node = this.ctx.openGroup();
+        const node: Node = this.ctx.openGroup("text");
 
         if (color) {
             this.ctx.attributes.fill = color;
@@ -149,7 +149,7 @@ export class SvgVexFlowBackend extends VexFlowBackend {
     }
     public renderRectangle(rectangle: RectangleF2D, styleId: number, colorHex: string, alpha: number = 1): Node {
         this.ctx.save();
-        const node: Node = this.ctx.openGroup();
+        const node: Node = this.ctx.openGroup("rect");
         if (colorHex) {
             this.ctx.attributes.fill = colorHex;
         } else {
@@ -165,7 +165,7 @@ export class SvgVexFlowBackend extends VexFlowBackend {
 
     public renderLine(start: PointF2D, stop: PointF2D, color: string = "#FF0000FF", lineWidth: number = 2): Node {
         this.ctx.save();
-        const node: Node = this.ctx.openGroup();
+        const node: Node = this.ctx.openGroup("line");
         this.ctx.beginPath();
         this.ctx.moveTo(start.x, start.y);
         this.ctx.lineTo(stop.x, stop.y);
@@ -184,7 +184,7 @@ export class SvgVexFlowBackend extends VexFlowBackend {
     }
 
     public renderCurve(points: PointF2D[]): Node {
-        const node: Node = this.ctx.openGroup();
+        const node: Node = this.ctx.openGroup("curve");
         this.ctx.beginPath();
         this.ctx.moveTo(points[0].x, points[0].y);
         this.ctx.bezierCurveTo(

+ 2 - 0
src/MusicalScore/Graphical/VexFlow/VexFlowConverter.ts

@@ -463,6 +463,8 @@ export class VexFlowConverter {
             vfnote = new Vex.Flow.GraceNote(vfnoteStruct);
         } else {
             vfnote = new Vex.Flow.StaveNote(vfnoteStruct);
+            (vfnote as any).stagger_same_whole_notes = rules.StaggerSameWholeNotes;
+            //   it would be nice to only save this once, not for every note, but has to be accessible in stavenote.js
         }
         const lineShift: number = gve.notes[0].lineShift;
         if (lineShift !== 0) {

+ 20 - 0
src/MusicalScore/Graphical/VexFlow/VexFlowGraphicalNote.ts

@@ -122,4 +122,24 @@ export class VexFlowGraphicalNote extends GraphicalNote {
         }
         return this.vfnote[0].getAttribute("el");
     }
+
+    /** Gets the SVG path element of the note's stem. */
+    public getStemSVG(): HTMLElement {
+        return document.getElementById("vf-" + this.getSVGId() + "-stem");
+        // more correct, but Vex.Prefix() is not in the definitions:
+        //return document.getElementById((Vex as any).Prefix(this.getSVGId() + "-stem"));
+    }
+
+    /** Gets the SVG path elements of the beams starting on this note. */
+    public getBeamSVGs(): HTMLElement[] {
+        const beamSVGs: HTMLElement[] = [];
+        for (let i: number = 0;; i++) {
+            const newSVG: HTMLElement = document.getElementById(`vf-${this.getSVGId()}-beam${i}`);
+            if (!newSVG) {
+                break;
+            }
+            beamSVGs.push(newSVG);
+        }
+        return beamSVGs;
+    }
 }

+ 4 - 1
src/MusicalScore/Graphical/VexFlow/VexFlowMusicSheetCalculator.ts

@@ -812,7 +812,10 @@ export class VexFlowMusicSheetCalculator extends MusicSheetCalculator {
     const firstMeasureNumber: number = this.graphicalMusicSheet.MeasureList[0][0].MeasureNumber; // 0 for pickup, 1 otherwise
     const measureNumber: number = Math.max(measure.MeasureNumber - firstMeasureNumber, 0);
     const staffNumber: number = 0;
-    const vfStave: Vex.Flow.Stave = (this.graphicalMusicSheet.MeasureList[measureNumber][staffNumber] as VexFlowMeasure).getVFStave();
+    const vfStave: Vex.Flow.Stave = (this.graphicalMusicSheet.MeasureList[measureNumber][staffNumber] as VexFlowMeasure)?.getVFStave();
+    if (!vfStave) { // potentially multi measure rest
+      return;
+    }
     const yOffset: number = -this.rules.RehearsalMarkYOffsetDefault - this.rules.RehearsalMarkYOffset;
     let xOffset: number = this.rules.RehearsalMarkXOffsetDefault + this.rules.RehearsalMarkXOffset;
     if (measure.IsSystemStartMeasure) {

+ 55 - 3
src/MusicalScore/ScoreIO/InstrumentReader.ts

@@ -911,9 +911,9 @@ export class InstrumentReader {
           }
 
         }
-        if (nodeList.hasAttributes && nodeList.attributes()[0].name === "number") {
+        if (nodeList.hasAttributes && nodeList.attribute("number")) {
           try {
-            staffNumber = parseInt(nodeList.attributes()[0].value, 10);
+            staffNumber = parseInt(nodeList.attribute("number").value, 10);
             if (staffNumber > this.currentClefNumber) {
               staffNumber = this.currentClefNumber;
             }
@@ -1065,7 +1065,7 @@ export class InstrumentReader {
   private saveAbstractInstructionList(numberOfStaves: number, beginOfMeasure: boolean): void {
     for (let i: number = this.abstractInstructions.length - 1; i >= 0; i--) {
       const instruction: [number, AbstractNotationInstruction, Fraction] = this.abstractInstructions[i];
-      const key: number = instruction[0];
+      const key: number = instruction[0]; // staffNumber
       const value: AbstractNotationInstruction = instruction[1];
       const instructionTimestamp: Fraction = instruction[2];
       if (value instanceof ClefInstruction) {
@@ -1140,6 +1140,58 @@ export class InstrumentReader {
               this.activeClefs[key - 1] = clefInstruction;
               this.abstractInstructions.splice(i, 1);
             }
+          } else {
+            let lastStaffEntryBefore: SourceStaffEntry;
+            const duration: Fraction = this.activeRhythm.Rhythm;
+            if (duration.RealValue > 0 &&
+              instructionTimestamp.RealValue / duration.RealValue > 0.90) {
+                if (!this.currentMeasure.LastInstructionsStaffEntries[key - 1]) {
+                  this.currentMeasure.LastInstructionsStaffEntries[key - 1] = new SourceStaffEntry(undefined, this.instrument.Staves[key - 1]);
+                }
+                lastStaffEntryBefore = this.currentMeasure.LastInstructionsStaffEntries[key - 1];
+            }
+            // TODO figure out a more elegant way to do this. (see #1120)
+            //   the problem is that not all the staffentries in the measure exist yet,
+            //   so we can't put the clefInstruction before the correct note.
+            //   (if we try that, it's one note too early -> save instruction for later?)
+            //let lastTimestampBefore: Fraction;
+            // for (const vssec of this.currentMeasure.VerticalSourceStaffEntryContainers) {
+            //   for (const sse of vssec.StaffEntries) {
+            //     if (sse?.ParentStaff?.Id !== key) {
+            //       continue;
+            //     }
+            //     // if (!lastTimestampBefore || sse.Timestamp.lte(instructionTimestamp)) {
+            //     //   lastTimestampBefore = sse.Timestamp;
+            //     //   lastStaffEntryBefore = sse;
+            //     // } else {
+            //     //   lastStaffEntryBefore = sse;
+            //     //   break;
+            //     // }
+            //     if (sse.Timestamp.gte(instructionTimestamp)) {
+            //       lastStaffEntryBefore = sse;
+            //       break;
+            //     }
+            //   }
+            // }
+            //const sseIndex: number = this.inSourceMeasureInstrumentIndex + staffNumber - 1;
+            // if (!lastStaffEntryBefore) {
+            //   // this doesn't work for some reason
+            //   const newContainer: VerticalSourceStaffEntryContainer = new VerticalSourceStaffEntryContainer(this.currentMeasure, instructionTimestamp, 1);
+            //   const newStaffEntry: SourceStaffEntry = new SourceStaffEntry(newContainer, this.instrument.Staves[key - 1]);
+            //   newContainer.StaffEntries.push(newStaffEntry);
+            //   this.currentMeasure.VerticalSourceStaffEntryContainers.push(newContainer);
+            //   lastStaffEntryBefore = newStaffEntry;
+            // }
+            // if (!lastStaffEntryBefore) {
+              //   lastStaffEntryBefore = new SourceStaffEntry(undefined, undefined);
+              //   this.currentMeasure.LastInstructionsStaffEntries[sseIndex] = lastStaffEntryBefore;
+              // }
+            if (lastStaffEntryBefore) {
+              clefInstruction.Parent = lastStaffEntryBefore;
+              lastStaffEntryBefore.Instructions.push(clefInstruction);
+              this.activeClefs[key - 1] = clefInstruction;
+              this.abstractInstructions.splice(i, 1);
+            } // else clefinstruction might be processed later (e.g. Haydn Concertante measure 314)
           }
         } else if (key <= this.activeClefs.length && clefInstruction === this.activeClefs[key - 1]) {
           this.abstractInstructions.splice(i, 1);

+ 25 - 18
src/MusicalScore/ScoreIO/VoiceGenerator.ts

@@ -259,15 +259,16 @@ export class VoiceGenerator {
         }
 
         // remove open ties, if there is already a gap between the last tie note and now.
-        const openTieDict: { [_: number]: Tie } = this.openTieDict;
-        for (const key in openTieDict) {
-          if (openTieDict.hasOwnProperty(key)) {
-            const tie: Tie = openTieDict[key];
-            if (Fraction.plus(tie.StartNote.ParentStaffEntry.Timestamp, tie.Duration).lt(this.currentStaffEntry.Timestamp)) {
-              delete openTieDict[key];
-            }
-          }
-        }
+        // TODO this deletes valid ties, see #1097
+        // const openTieDict: { [_: number]: Tie } = this.openTieDict;
+        // for (const key in openTieDict) {
+        //   if (openTieDict.hasOwnProperty(key)) {
+        //     const tie: Tie = openTieDict[key];
+        //     if (Fraction.plus(tie.StartNote.ParentStaffEntry.Timestamp, tie.Duration).lt(this.currentStaffEntry.Timestamp)) {
+        //       delete openTieDict[key];
+        //     }
+        //   }
+        // }
       }
       // time-modification yields tuplet in currentNote
       // mustn't execute method, if this is the Note where the Tuplet has been created
@@ -988,8 +989,8 @@ export class VoiceGenerator {
 
   private addTie(tieNodeList: IXmlElement[], measureStartAbsoluteTimestamp: Fraction, maxTieNoteFraction: Fraction, tieType: TieTypes): void {
     if (tieNodeList) {
-      if (tieNodeList.length === 1) {
-        const tieNode: IXmlElement = tieNodeList[0];
+      for (let i: number = 0; i < tieNodeList.length; i++) {
+        const tieNode: IXmlElement = tieNodeList[i];
         if (tieNode !== undefined && tieNode.attributes()) {
           let tieDirection: PlacementEnum = PlacementEnum.NotYetDefined;
           // read tie direction/placement from XML
@@ -1012,6 +1013,11 @@ export class VoiceGenerator {
           }
 
           const type: string = tieNode.attribute("type").value;
+          if (type === "start" && i === 0) {
+            // handle this after the stop node, so that we don't start a new tie before the old one has ended.
+            tieNodeList.push(tieNode);
+            continue;
+          }
           try {
             if (type === "start") {
               const num: number = this.findCurrentNoteInTieDict(this.currentNote);
@@ -1027,7 +1033,7 @@ export class VoiceGenerator {
               const tieNumber: number = this.findCurrentNoteInTieDict(this.currentNote);
               const tie: Tie = this.openTieDict[tieNumber];
               if (tie) {
-                tie.AddNote(this.currentNote);
+                tie.AddNote(this.currentNote, false);
                 delete this.openTieDict[tieNumber];
               }
             }
@@ -1037,13 +1043,14 @@ export class VoiceGenerator {
           }
 
         }
-      } else if (tieNodeList.length === 2) {
-        const tieNumber: number = this.findCurrentNoteInTieDict(this.currentNote);
-        if (tieNumber >= 0) {
-          const tie: Tie = this.openTieDict[tieNumber];
-          tie.AddNote(this.currentNote);
-        }
       }
+      // } else if (tieNodeList.length === 2) {
+      //   const tieNumber: number = this.findCurrentNoteInTieDict(this.currentNote);
+      //   if (tieNumber >= 0) {
+      //     const tie: Tie = this.openTieDict[tieNumber];
+      //     tie.AddNote(this.currentNote);
+      //   }
+      // }
     }
   }
 

+ 4 - 2
src/MusicalScore/VoiceData/Tie.ts

@@ -43,8 +43,10 @@ export class Tie {
         return this.StartNote.Pitch;
     }
 
-    public AddNote(note: Note): void {
+    public AddNote(note: Note, isStartNote: boolean = true): void {
         this.notes.push(note);
-        note.NoteTie = this;
+        if (isStartNote) {
+            note.NoteTie = this; // be careful not to overwrite note.NoteTie wrongly, saves only one tie
+        }
     }
 }

+ 1 - 1
src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts

@@ -35,7 +35,7 @@ import { DynamicsCalculator } from "../MusicalScore/ScoreIO/MusicSymbolModules/D
  * After the constructor, use load() and render() to load and render a MusicXML file.
  */
 export class OpenSheetMusicDisplay {
-    private version: string = "1.3.1-audio-extended"; // getter: this.Version
+    private version: string = "1.4.0-audio-extended"; // getter: this.Version
     // at release, bump version and change to -release, afterwards to -dev again
 
     /**

+ 9 - 1
src/VexFlowPatch/readme.txt

@@ -12,6 +12,7 @@ Each .js has comments like "// VexFlowPatch: [explanation]" to indicate what was
 
 beam.js (custom addition):
 add flat_beams, flat_beam_offset, flat_beam_offset_per_beam render_option
+able to add svg node id+class to beam
 
 pedalmarking.js (custom addition):
 Add rendering options for pedals that break across systems.
@@ -23,7 +24,8 @@ add xOffset, fontSize arguments (see stavesection.js)
 
 stavenote.js (custom addition):
 Fix stem/flag formatting. Instead of shifting notes by default, update the stem/flag rendering to render different voices aligned.
-Only offset if a note is the same voice, same note.
+  Only offset if a note is the same voice, same note.
+able to add svg node id+class to stem
 
 staverepetition.js (custom addition):
 add TO_CODA enum to type() and draw()
@@ -38,9 +40,15 @@ stavevolta.js (merged Vexflow 3.x):
 Fix the length of voltas for first measures in a system
 (whose lengths were wrongly extended by the width of the clef, key signature, etc. (beginInstructions) in Vexflow 1.2.93)
 
+stem.js (custom addition):
+able to give an id+class to the stem node in SVG
+
 stemmablenote.js (custom addition):
 Add manual flag rendering variable so we can choose not to render flags if notes are sharing a stem.
 
+svgcontext.js (custom addition):
+able to add extra attributes (like svg node id) to a stroke (e.g. stem)
+
 tabnote.js (merged Vexflow 3.x):
 Add a context group for each tabnote, so that it can be found in the SVG DOM ("vf-tabnote")
 

+ 17 - 3
src/VexFlowPatch/src/beam.js

@@ -751,7 +751,7 @@ export class Beam extends Element {
           }
         } else {
           // No beam started yet. Start a new one.
-          current_beam = { start: stem_x, end: null };
+          current_beam = { start: stem_x, end: null, start_note: note };
           beam_started = true;
 
           if (beam_alone) {
@@ -822,15 +822,24 @@ export class Beam extends Element {
     const firstStemX = firstNote.getStemX();
     const beamThickness = this.render_options.beam_width * this.stem_direction;
 
+    const beamsPerStartNote = {};
+    for (const note of this.notes) {
+      beamsPerStartNote[note.getAttribute("id")] = 0;
+    }
+    
     // Draw the beams.
     for (let i = 0; i < valid_beam_durations.length; ++i) {
       const duration = valid_beam_durations[i];
       const beamLines = this.getBeamLines(duration);
-
+      
       for (let j = 0; j < beamLines.length; ++j) {
         const beam_line = beamLines[j];
         const startBeamX = beam_line.start;
 
+        const startNoteId = beam_line.start_note.getAttribute("id");
+        const beamNumber = beamsPerStartNote[startNoteId];
+        beamsPerStartNote[startNoteId]++;
+
         const startBeamY = this.getSlopeY(startBeamX, firstStemX, beamY, this.slope);
         const lastBeamX = beam_line.end;
         const lastBeamY = this.getSlopeY(lastBeamX, firstStemX, beamY, this.slope);
@@ -841,7 +850,12 @@ export class Beam extends Element {
         this.context.lineTo(lastBeamX + 1, lastBeamY + beamThickness);
         this.context.lineTo(lastBeamX + 1, lastBeamY);
         this.context.closePath();
-        this.context.fill();
+        if (this.context.svg) {
+          const noteSVGId = Vex.Prefix(startNoteId);
+          this.context.fill({class: Vex.Prefix("beam"), id: `${noteSVGId}-beam${beamNumber}`});
+        } else {
+          this.context.fill();
+        }
       }
 
       beamY += beamThickness * 1.5;

+ 73 - 48
src/VexFlowPatch/src/stavenote.js

@@ -69,11 +69,16 @@ export class StaveNote extends StemmableNote {
     //   * 2 voices can be formatted *with or without* a stave being set but
     //     the output will be different
     //   * 3 voices can only be formatted *without* a stave
-    if (notes[0].getStave()) {
-      return StaveNote.formatByY(notes, state);
-    }
+
+    // if this is enabled, notes are sometimes not staggered correctly, see setXShift lines below 
+    // if (notes[0].getStave()) {
+    //   return StaveNote.formatByY(notes, state); // 
+    // }
 
     const notesList = [];
+    const stagger_same_whole_notes = notes[0].stagger_same_whole_notes;
+    // whether to stagger whole notes on the same line but different voice (show 2 instead of 1).
+    //   controlled by EngravingRules.StaggerSameWholeNotes
 
     for (let i = 0; i < notes.length; i++) {
       const props = notes[i].getKeyProps();
@@ -153,57 +158,74 @@ export class StaveNote extends StemmableNote {
           //If we are sharing a line, switch one notes stem direction.
           //If we are sharing a line and in the same voice, only then offset one note
           const lineDiff = Math.abs(noteU.line - noteL.line);
-          if (noteU.note.glyph.stem && noteL.note.glyph.stem) {
-            //If we have different dot values, must offset
-            //Or If we have a non-filled in mixed with a filled in notehead, must offset
-            if ((noteU.note.duration === "h" && noteL.note.duration !== "h") || 
-                (noteU.note.duration !== "h" && noteL.note.duration === "h") ||
-                 noteU.note.dots !== noteL.note.dots) {
-                noteL.note.setXShift(xShift);
-                if (noteU.note.dots > 0) {
-                  let foundDots = 0;
-                  for (const modifier of noteU.note.modifiers) {
-                    if (modifier instanceof Dot) {
-                      foundDots++;
-                      //offset dot(s) above the shifted note
-                      //lines + 1 to negative pixels
-                      modifier.setYShift(-10 * (noteL.maxLine - noteU.line + 1));
-                      if (foundDots === noteU.note.dots) {
-                        break;
-                      }
-                    }
+          //if (noteU.note.glyph.stem && noteL.note.glyph.stem) { // skip this condition: whole notes also relevant
+          //If we have different dot values, must offset
+          //Or If we have a non-filled in mixed with a filled in notehead, must offset
+          let halfNoteCount = 0;
+          let wholeNoteCount = 0;
+          if (noteU.note.duration === "h") {
+            halfNoteCount++;
+          } else if (noteU.note.duration === "w") {
+            wholeNoteCount++;
+          }
+          if (noteL.note.duration === "h") {
+            halfNoteCount++;
+          } else if (noteL.note.duration === "w") {
+            wholeNoteCount++;
+          }
+          // only stagger/x-shift if one of the notes is whole or half note and the other isn't. (or dots different)
+          let staggerConditions = halfNoteCount === 1 || wholeNoteCount === 1 || noteU.note.dots !== noteL.note.dots;
+          if (stagger_same_whole_notes) { // controlled by EngravingRules.StaggerSameWholeNotes. see declaration above
+            staggerConditions = staggerConditions || wholeNoteCount === 2;
+            // should be ||=, but appveyor says syntax error, doesn't know the operator.
+          }
+          if (lineDiff === 0 && staggerConditions) {
+            noteL.note.setXShift(xShift);
+            if (noteU.note.dots > 0) {
+              let foundDots = 0;
+              for (const modifier of noteU.note.modifiers) {
+                if (modifier instanceof Dot) {
+                  foundDots++;
+                  //offset dot(s) above the shifted note
+                  //lines + 1 to negative pixels
+                  modifier.setYShift(-10 * (noteL.maxLine - noteU.line + 1));
+                  if (foundDots === noteU.note.dots) {
+                    break;
                   }
                 }
-            } else if (lineDiff < 1 && lineDiff > 0) {//if the notes are quite close but not on the same line, shift
-              noteL.note.setXShift(xShift);
-            } else if (noteU.note.voice !== noteL.note.voice) {//If we are not in the same voice
-              if (noteU.stemDirection === noteL.stemDirection) {
-                if (noteU.line > noteL.line) {
-                  //noteU is above noteL
-                  if (noteU.stemDirection === 1) {
-                    noteL.note.renderFlag = false;
-                  } else {
-                    noteU.note.renderFlag = false;
-                  }
-                } else if (noteL.line > noteU.line) {
-                  //note L is above noteU
-                  if (noteL.stemDirection === 1) {
-                    noteU.note.renderFlag = false;
-                  } else {
-                    noteL.note.renderFlag = false;
-                  }
+              }
+            }
+          } else if (lineDiff < 1 && lineDiff > 0) {//if the notes are quite close but not on the same line, shift
+            noteL.note.setXShift(xShift);
+          } else if (noteU.note.voice !== noteL.note.voice) {//If we are not in the same voice
+            if (noteU.stemDirection === noteL.stemDirection) {
+              if (noteU.line > noteL.line) {
+                //noteU is above noteL
+                if (noteU.stemDirection === 1) {
+                  noteL.note.renderFlag = false;
                 } else {
-                  //same line, swap stem direction for one note
-                  if (noteL.stemDirection === 1) {
-                    noteL.stemDirection = -1;
-                    noteL.note.setStemDirection(-1);
-                  }
+                  noteU.note.renderFlag = false;
+                }
+              } else if (noteL.line > noteU.line) {
+                //note L is above noteU
+                if (noteL.stemDirection === 1) {
+                  noteU.note.renderFlag = false;
+                } else {
+                  noteL.note.renderFlag = false;
+                }
+              } else {
+                //same line, swap stem direction for one note
+                if (noteL.stemDirection === 1) {
+                  noteL.stemDirection = -1;
+                  noteL.note.setStemDirection(-1);
                 }
               }
-            } //Very close whole notes
-          } else if ((!noteU.note.glyph.stem && !noteL.note.glyph.stem && lineDiff < 1.5)) {
-            noteL.note.setXShift(xShift);
+            }
           }
+          //Very close whole notes
+          // } else if ((!noteU.note.glyph.stem && !noteL.note.glyph.stem && lineDiff < 1.5)) {
+          //   noteL.note.setXShift(xShift);
+          // }
         }
       }
 
@@ -426,6 +448,7 @@ export class StaveNote extends StemmableNote {
   // Builds a `Stem` for the note
   buildStem() {
     this.setStem(new Stem({ hide: !!this.isRest(), }));
+    this.stem.id = Vex.Prefix(`${this.getAttribute("id")}-stem`);
   }
 
   // Builds a `NoteHead` for each key in the note
@@ -1157,6 +1180,8 @@ export class StaveNote extends StemmableNote {
     if (stemStruct) {
       this.setStem(new Stem(stemStruct));
     }
+    // seems to not get called here, see this.stem.id above
+    this.stem.id = Vex.Prefix(`${this.getAttribute("id")}-stem`);
 
     if (this.stem) {
       this.context.openGroup('stem', null, { pointerBBox: true });

+ 175 - 0
src/VexFlowPatch/src/stem.js

@@ -0,0 +1,175 @@
+// [VexFlow](http://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
+//
+// ## Description
+// This file implements the `Stem` object. Generally this object is handled
+// by its parent `StemmableNote`.
+
+import { Vex } from './vex';
+import { Element } from './element';
+import { Flow } from './tables';
+
+// To enable logging for this class. Set `Vex.Flow.Stem.DEBUG` to `true`.
+function L(...args) { if (Stem.DEBUG) Vex.L('Vex.Flow.Stem', args); }
+
+export class Stem extends Element {
+  static get CATEGORY() { return 'stem'; }
+
+  // Stem directions
+  static get UP() {
+    return 1;
+  }
+  static get DOWN() {
+    return -1;
+  }
+
+  // Theme
+  static get WIDTH() {
+    return Flow.STEM_WIDTH;
+  }
+  static get HEIGHT() {
+    return Flow.STEM_HEIGHT;
+  }
+
+  constructor(options = {}) {
+    super();
+    this.setAttribute('type', 'Stem');
+
+    // Default notehead x bounds
+    this.x_begin = options.x_begin || 0;
+    this.x_end = options.x_end || 0;
+
+    // Y bounds for top/bottom most notehead
+    this.y_top = options.y_top || 0;
+    this.y_bottom = options.y_bottom || 0;
+
+    // Stem top extension
+    this.stem_extension = options.stem_extension || 0;
+
+    // Direction of the stem
+    this.stem_direction = options.stem_direction || 0;
+
+    // Flag to override all draw calls
+    this.hide = options.hide || false;
+
+    this.isStemlet = options.isStemlet || false;
+    this.stemletHeight = options.stemletHeight || 0;
+
+    // Use to adjust the rendered height without affecting
+    // the results of `.getExtents()`
+    this.renderHeightAdjustment = 0;
+    this.setOptions(options);
+  }
+
+  setOptions(options) {
+    // Changing where the stem meets the head
+    this.stem_up_y_offset = options.stem_up_y_offset || 0;
+    this.stem_down_y_offset = options.stem_down_y_offset || 0;
+  }
+
+  // Set the x bounds for the default notehead
+  setNoteHeadXBounds(x_begin, x_end) {
+    this.x_begin = x_begin;
+    this.x_end = x_end;
+    return this;
+  }
+
+  // Set the direction of the stem in relation to the noteheads
+  setDirection(direction) { this.stem_direction = direction; }
+
+  // Set the extension for the stem, generally for flags or beams
+  setExtension(ext) { this.stem_extension = ext; }
+  getExtension() { return this.stem_extension; }
+
+  // The the y bounds for the top and bottom noteheads
+  setYBounds(y_top, y_bottom) {
+    this.y_top = y_top;
+    this.y_bottom = y_bottom;
+  }
+
+  // The category of the object
+  getCategory() { return Stem.CATEGORY; }
+
+  // Gets the entire height for the stem
+  getHeight() {
+    const y_offset = (this.stem_direction === Stem.UP) ? this.stem_up_y_offset : this.stem_down_y_offset; // eslint-disable-line max-len
+    return ((this.y_bottom - this.y_top) * this.stem_direction) +
+           ((Stem.HEIGHT - y_offset + this.stem_extension) * this.stem_direction);
+  }
+  getBoundingBox() {
+    throw new Vex.RERR('NotImplemented', 'getBoundingBox() not implemented.');
+  }
+
+  // Get the y coordinates for the very base of the stem to the top of
+  // the extension
+  getExtents() {
+    const isStemUp = this.stem_direction === Stem.UP;
+    const ys = [this.y_top, this.y_bottom];
+    const stemHeight = Stem.HEIGHT + this.stem_extension;
+
+    const innerMostNoteheadY = (isStemUp ? Math.min : Math.max)(...ys);
+    const outerMostNoteheadY = (isStemUp ? Math.max : Math.min)(...ys);
+    const stemTipY = innerMostNoteheadY + (stemHeight * -this.stem_direction);
+
+    return { topY: stemTipY, baseY: outerMostNoteheadY };
+  }
+
+  setVisibility(isVisible) {
+    this.hide = !isVisible;
+    return this;
+  }
+
+  setStemlet(isStemlet, stemletHeight) {
+    this.isStemlet = isStemlet;
+    this.stemletHeight = stemletHeight;
+    return this;
+  }
+
+  // Render the stem onto the canvas
+  draw() {
+    this.setRendered();
+    if (this.hide) return;
+    const ctx = this.checkContext();
+
+    let stem_x;
+    let stem_y;
+    const stem_direction = this.stem_direction;
+
+    if (stem_direction === Stem.DOWN) {
+      // Down stems are rendered to the left of the head.
+      stem_x = this.x_begin;
+      stem_y = this.y_top + this.stem_down_y_offset;
+    } else {
+      // Up stems are rendered to the right of the head.
+      stem_x = this.x_end;
+      stem_y = this.y_bottom - this.stem_up_y_offset;
+    }
+
+    const stemHeight = this.getHeight();
+
+    L('Rendering stem - ', 'Top Y: ', this.y_top, 'Bottom Y: ', this.y_bottom);
+
+    // The offset from the stem's base which is required fo satisfy the stemlet height
+    const stemletYOffset = this.isStemlet
+      ? stemHeight - this.stemletHeight * this.stem_direction
+      : 0;
+
+    // Draw the stem
+    ctx.save();
+    this.applyStyle(ctx);
+    ctx.beginPath();
+    ctx.setLineWidth(Stem.WIDTH);
+    ctx.moveTo(stem_x, stem_y - stemletYOffset);
+    ctx.lineTo(stem_x, stem_y - stemHeight - (this.renderHeightAdjustment * stem_direction));
+    if (ctx.svg) {
+        const strokeAttributes = {class: Vex.Prefix("stem")};
+        if (this.id) {
+            strokeAttributes.id = this.id;
+        }
+        ctx.stroke(strokeAttributes);
+    } else {
+        ctx.stroke();
+    }
+    this.restoreStyle(ctx);
+    ctx.restore();
+  }
+}

+ 692 - 0
src/VexFlowPatch/src/svgcontext.js

@@ -0,0 +1,692 @@
+// [VexFlow](http://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
+// @author Gregory Ristow (2015)
+
+import { Vex } from './vex';
+
+const attrNamesToIgnoreMap = {
+  path: {
+    x: true,
+    y: true,
+    width: true,
+    height: true,
+  },
+  rect: {
+  },
+  text: {
+    width: true,
+    height: true,
+  },
+};
+
+{
+  const fontAttrNamesToIgnore = {
+    'font-family': true,
+    'font-weight': true,
+    'font-style': true,
+    'font-size': true,
+  };
+
+  Vex.Merge(attrNamesToIgnoreMap.rect, fontAttrNamesToIgnore);
+  Vex.Merge(attrNamesToIgnoreMap.path, fontAttrNamesToIgnore);
+}
+
+export class SVGContext {
+  constructor(element) {
+    // element is the parent DOM object
+    this.element = element;
+    // Create the SVG in the SVG namespace:
+    this.svgNS = 'http://www.w3.org/2000/svg';
+    const svg = this.create('svg');
+    // Add it to the canvas:
+    this.element.appendChild(svg);
+
+    // Point to it:
+    this.svg = svg;
+    this.groups = [this.svg]; // Create the group stack
+    this.parent = this.svg;
+
+    this.path = '';
+    this.pen = { x: NaN, y: NaN };
+    this.lineWidth = 1.0;
+    this.state = {
+      scale: { x: 1, y: 1 },
+      'font-family': 'Arial',
+      'font-size': '8pt',
+      'font-weight': 'normal',
+    };
+
+    this.attributes = {
+      'stroke-width': 0.3,
+      'fill': 'black',
+      'stroke': 'black',
+      'stroke-dasharray': 'none',
+      'font-family': 'Arial',
+      'font-size': '10pt',
+      'font-weight': 'normal',
+      'font-style': 'normal',
+    };
+
+    this.background_attributes = {
+      'stroke-width': 0,
+      'fill': 'white',
+      'stroke': 'white',
+      'stroke-dasharray': 'none',
+      'font-family': 'Arial',
+      'font-size': '10pt',
+      'font-weight': 'normal',
+      'font-style': 'normal',
+    };
+
+    this.shadow_attributes = {
+      width: 0,
+      color: 'black',
+    };
+
+    this.state_stack = [];
+
+    // Test for Internet Explorer
+    this.iePolyfill();
+  }
+
+  create(svgElementType) {
+    return document.createElementNS(this.svgNS, svgElementType);
+  }
+
+  // Allow grouping elements in containers for interactivity.
+  openGroup(cls, id, attrs) {
+    const group = this.create('g');
+    this.groups.push(group);
+    this.parent.appendChild(group);
+    this.parent = group;
+    if (cls) group.setAttribute('class', Vex.Prefix(cls));
+    if (id) group.setAttribute('id', Vex.Prefix(id));
+
+    if (attrs && attrs.pointerBBox) {
+      group.setAttribute('pointer-events', 'bounding-box');
+    }
+    return group;
+  }
+
+  closeGroup() {
+    this.groups.pop();
+    this.parent = this.groups[this.groups.length - 1];
+  }
+
+  add(elem) {
+    this.parent.appendChild(elem);
+  }
+
+  // Tests if the browser is Internet Explorer; if it is,
+  // we do some tricks to improve text layout.  See the
+  // note at ieMeasureTextFix() for details.
+  iePolyfill() {
+    if (typeof (navigator) !== 'undefined') {
+      this.ie = (
+        /MSIE 9/i.test(navigator.userAgent) ||
+        /MSIE 10/i.test(navigator.userAgent) ||
+        /rv:11\.0/i.test(navigator.userAgent) ||
+        /Trident/i.test(navigator.userAgent)
+      );
+    }
+  }
+
+  // ### Styling & State Methods:
+
+  setFont(family, size, weight) {
+    // Unlike canvas, in SVG italic is handled by font-style,
+    // not weight. So: we search the weight argument and
+    // apply bold and italic to weight and style respectively.
+    let bold = false;
+    let italic = false;
+    let style = 'normal';
+    // Weight might also be a number (200, 400, etc...) so we
+    // test its type to be sure we have access to String methods.
+    if (typeof weight === 'string') {
+      // look for "italic" in the weight:
+      if (weight.indexOf('italic') !== -1) {
+        weight = weight.replace(/italic/g, '');
+        italic = true;
+      }
+      // look for "bold" in weight
+      if (weight.indexOf('bold') !== -1) {
+        weight = weight.replace(/bold/g, '');
+        bold = true;
+      }
+      // remove any remaining spaces
+      weight = weight.replace(/ /g, '');
+    }
+    weight = bold ? 'bold' : weight;
+    weight = (typeof weight === 'undefined' || weight === '') ? 'normal' : weight;
+
+    style = italic ? 'italic' : style;
+
+    const fontAttributes = {
+      'font-family': family,
+      'font-size': size + 'pt',
+      'font-weight': weight,
+      'font-style': style,
+    };
+
+    // Store the font size so that if the browser is Internet
+    // Explorer we can fix its calculations of text width.
+    this.fontSize = Number(size);
+
+    Vex.Merge(this.attributes, fontAttributes);
+    Vex.Merge(this.state, fontAttributes);
+
+    return this;
+  }
+
+  setRawFont(font) {
+    font = font.trim();
+    // Assumes size first, splits on space -- which is presently
+    // how all existing modules are calling this.
+    const fontArray = font.split(' ');
+
+    this.attributes['font-family'] = fontArray[1];
+    this.state['font-family'] = fontArray[1];
+
+    this.attributes['font-size'] = fontArray[0];
+    this.state['font-size'] = fontArray[0];
+
+    // Saves fontSize for IE polyfill
+    this.fontSize = Number(fontArray[0].match(/\d+/));
+    return this;
+  }
+
+  setFillStyle(style) {
+    this.attributes.fill = style;
+    return this;
+  }
+
+  setBackgroundFillStyle(style) {
+    this.background_attributes.fill = style;
+    this.background_attributes.stroke = style;
+    return this;
+  }
+
+  setStrokeStyle(style) {
+    this.attributes.stroke = style;
+    return this;
+  }
+
+  setShadowColor(style) {
+    this.shadow_attributes.color = style;
+    return this;
+  }
+
+  setShadowBlur(blur) {
+    this.shadow_attributes.width = blur;
+    return this;
+  }
+
+  setLineWidth(width) {
+    this.attributes['stroke-width'] = width;
+    this.lineWidth = width;
+  }
+
+  // @param array {lineDash} as [dashInt, spaceInt, dashInt, spaceInt, etc...]
+  setLineDash(lineDash) {
+    if (Object.prototype.toString.call(lineDash) === '[object Array]') {
+      lineDash = lineDash.join(', ');
+      this.attributes['stroke-dasharray'] = lineDash;
+      return this;
+    } else {
+      throw new Vex.RERR('ArgumentError', 'lineDash must be an array of integers.');
+    }
+  }
+
+  setLineCap(lineCap) {
+    this.attributes['stroke-linecap'] = lineCap;
+    return this;
+  }
+
+  // ### Sizing & Scaling Methods:
+
+  // TODO (GCR): See note at scale() -- seperate our internal
+  // conception of pixel-based width/height from the style.width
+  // and style.height properties eventually to allow users to
+  // apply responsive sizing attributes to the SVG.
+  resize(width, height) {
+    this.width = width;
+    this.height = height;
+    this.element.style.width = width;
+    const attributes = {
+      width,
+      height,
+    };
+    this.applyAttributes(this.svg, attributes);
+    this.scale(this.state.scale.x, this.state.scale.y);
+    return this;
+  }
+
+  scale(x, y) {
+    // uses viewBox to scale
+    // TODO (GCR): we may at some point want to distinguish the
+    // style.width / style.height properties that are applied to
+    // the SVG object from our internal conception of the SVG
+    // width/height.  This would allow us to create automatically
+    // scaling SVG's that filled their containers, for instance.
+    //
+    // As this isn't implemented in Canvas or Raphael contexts,
+    // I've left as is for now, but in using the viewBox to
+    // handle internal scaling, am trying to make it possible
+    // for us to eventually move in that direction.
+
+    this.state.scale = { x, y };
+    const visibleWidth = this.width / x;
+    const visibleHeight = this.height / y;
+    this.setViewBox(0, 0, visibleWidth, visibleHeight);
+
+    return this;
+  }
+
+  setViewBox(...args) {
+    // Override for "x y w h" style:
+    if (args.length === 1) {
+      const [viewBox] = args;
+      this.svg.setAttribute('viewBox', viewBox);
+    } else {
+      const [xMin, yMin, width, height] = args;
+      const viewBoxString = xMin + ' ' + yMin + ' ' + width + ' ' + height;
+      this.svg.setAttribute('viewBox', viewBoxString);
+    }
+  }
+
+  // ### Drawing helper methods:
+
+  applyAttributes(element, attributes) {
+    const attrNamesToIgnore = attrNamesToIgnoreMap[element.nodeName];
+    Object
+      .keys(attributes)
+      .forEach(propertyName => {
+        if (attrNamesToIgnore && attrNamesToIgnore[propertyName]) {
+          return;
+        }
+        element.setAttributeNS(null, propertyName, attributes[propertyName]);
+      });
+
+    return element;
+  }
+
+  // ### Shape & Path Methods:
+
+  clear() {
+    // Clear the SVG by removing all inner children.
+
+    // (This approach is usually slightly more efficient
+    // than removing the old SVG & adding a new one to
+    // the container element, since it does not cause the
+    // container to resize twice.  Also, the resize
+    // triggered by removing the entire SVG can trigger
+    // a touchcancel event when the element resizes away
+    // from a touch point.)
+
+    while (this.svg.lastChild) {
+      this.svg.removeChild(this.svg.lastChild);
+    }
+
+    // Replace the viewbox attribute we just removed:
+    this.scale(this.state.scale.x, this.state.scale.y);
+  }
+
+  // ## Rectangles:
+
+  rect(x, y, width, height, attributes) {
+    // Avoid invalid negative height attribs by
+    // flipping the rectangle on its head:
+    if (height < 0) {
+      y += height;
+      height *= -1;
+    }
+
+    // Create the rect & style it:
+    const rectangle = this.create('rect');
+    if (typeof attributes === 'undefined') {
+      attributes = {
+        fill: 'none',
+        'stroke-width': this.lineWidth,
+        stroke: 'black',
+      };
+    }
+
+    Vex.Merge(attributes, {
+      x,
+      y,
+      width,
+      height,
+    });
+
+    this.applyAttributes(rectangle, attributes);
+
+    this.add(rectangle);
+    return this;
+  }
+
+  fillRect(x, y, width, height) {
+    if (height < 0) {
+      y += height;
+      height *= -1;
+    }
+
+    this.rect(x, y, width, height, this.attributes);
+    return this;
+  }
+
+  clearRect(x, y, width, height) {
+    // TODO(GCR): Improve implementation of this...
+    // Currently it draws a box of the background color, rather
+    // than creating alpha through lower z-levels.
+    //
+    // See the implementation of this in SVGKit:
+    // http://sourceforge.net/projects/svgkit/
+    // as a starting point.
+    //
+    // Adding a large number of transform paths (as we would
+    // have to do) could be a real performance hit.  Since
+    // tabNote seems to be the only module that makes use of this
+    // it may be worth creating a seperate tabStave that would
+    // draw lines around locations of tablature fingering.
+    //
+
+    this.rect(x, y, width, height, this.background_attributes);
+    return this;
+  }
+
+  // ## Paths:
+
+  beginPath() {
+    this.path = '';
+    this.pen.x = NaN;
+    this.pen.y = NaN;
+
+    return this;
+  }
+
+  moveTo(x, y) {
+    this.path += 'M' + x + ' ' + y;
+    this.pen.x = x;
+    this.pen.y = y;
+    return this;
+  }
+
+  lineTo(x, y) {
+    this.path += 'L' + x + ' ' + y;
+    this.pen.x = x;
+    this.pen.y = y;
+    return this;
+  }
+
+  bezierCurveTo(x1, y1, x2, y2, x, y) {
+    this.path += 'C' +
+      x1 + ' ' +
+      y1 + ',' +
+      x2 + ' ' +
+      y2 + ',' +
+      x + ' ' +
+      y;
+    this.pen.x = x;
+    this.pen.y = y;
+    return this;
+  }
+
+  quadraticCurveTo(x1, y1, x, y) {
+    this.path += 'Q' +
+      x1 + ' ' +
+      y1 + ',' +
+      x + ' ' +
+      y;
+    this.pen.x = x;
+    this.pen.y = y;
+    return this;
+  }
+
+  // This is an attempt (hack) to simulate the HTML5 canvas
+  // arc method.
+  arc(x, y, radius, startAngle, endAngle, antiClockwise) {
+    function normalizeAngle(angle) {
+      while (angle < 0) {
+        angle += Math.PI * 2;
+      }
+
+      while (angle > Math.PI * 2) {
+        angle -= Math.PI * 2;
+      }
+      return angle;
+    }
+
+    startAngle = normalizeAngle(startAngle);
+    endAngle = normalizeAngle(endAngle);
+
+    if (startAngle > endAngle) {
+      const tmp = startAngle;
+      startAngle = endAngle;
+      endAngle = tmp;
+      antiClockwise = !antiClockwise;
+    }
+
+    const delta = endAngle - startAngle;
+
+    if (delta > Math.PI) {
+      this.arcHelper(x, y, radius, startAngle, startAngle + delta / 2, antiClockwise);
+      this.arcHelper(x, y, radius, startAngle + delta / 2, endAngle, antiClockwise);
+    } else {
+      this.arcHelper(x, y, radius, startAngle, endAngle, antiClockwise);
+    }
+    return this;
+  }
+
+  arcHelper(x, y, radius, startAngle, endAngle, antiClockwise) {
+    const x1 = x + radius * Math.cos(startAngle);
+    const y1 = y + radius * Math.sin(startAngle);
+
+    const x2 = x + radius * Math.cos(endAngle);
+    const y2 = y + radius * Math.sin(endAngle);
+
+    let largeArcFlag = 0;
+    let sweepFlag = 0;
+    if (antiClockwise) {
+      sweepFlag = 1;
+      if (endAngle - startAngle < Math.PI) {
+        largeArcFlag = 1;
+      }
+    } else if (endAngle - startAngle > Math.PI) {
+      largeArcFlag = 1;
+    }
+
+    this.path += 'M' + x1 + ' ' + y1 + ' A' +
+      radius + ' ' + radius + ' 0 ' + largeArcFlag + ' ' + sweepFlag + ' ' +
+      x2 + ' ' + y2;
+    if (!isNaN(this.pen.x) && !isNaN(this.pen.y)) {
+      this.peth += 'M' + this.pen.x + ' ' + this.pen.y;
+    }
+  }
+
+  closePath() {
+    this.path += 'Z';
+
+    return this;
+  }
+
+  // Adapted from the source for Raphael's Element.glow
+  glow() {
+    // Calculate the width & paths of the glow:
+    if (this.shadow_attributes.width > 0) {
+      const sa = this.shadow_attributes;
+      const num_paths = sa.width / 2;
+      // Stroke at varying widths to create effect of gaussian blur:
+      for (let i = 1; i <= num_paths; i++) {
+        const attributes = {
+          stroke: sa.color,
+          'stroke-linejoin': 'round',
+          'stroke-linecap': 'round',
+          'stroke-width': +((sa.width * 0.4) / num_paths * i).toFixed(3),
+          opacity: +((sa.opacity || 0.3) / num_paths).toFixed(3),
+        };
+
+        const path = this.create('path');
+        attributes.d = this.path;
+        this.applyAttributes(path, attributes);
+        this.add(path);
+      }
+    }
+    return this;
+  }
+
+  fill(attributes) {
+    // If our current path is set to glow, make it glow
+    this.glow();
+
+    const path = this.create('path');
+    if (typeof attributes === 'undefined') {
+        attributes = {};
+        Vex.Merge(attributes, this.attributes);
+        attributes.stroke = 'none';
+    }
+    
+    attributes.d = this.path;
+    //attributes.class = "testbeam";
+    
+    this.applyAttributes(path, attributes);
+    this.add(path);
+    return this;
+  }
+
+  stroke(extraAttributes = undefined) {
+    // If our current path is set to glow, make it glow.
+    this.glow();
+
+    const path = this.create('path');
+    const attributes = {};
+    Vex.Merge(attributes, this.attributes);
+    if (extraAttributes) {
+        Vex.Merge(attributes, extraAttributes);
+    }
+    attributes.fill = 'none';
+    attributes['stroke-width'] = this.lineWidth;
+    attributes.d = this.path;
+
+    this.applyAttributes(path, attributes);
+    this.add(path);
+    return this;
+  }
+
+  // ## Text Methods:
+  measureText(text) {
+    const txt = this.create('text');
+    if (typeof (txt.getBBox) !== 'function') {
+      return { x: 0, y: 0, width: 0, height: 0 };
+    }
+
+    txt.textContent = text;
+    this.applyAttributes(txt, this.attributes);
+
+    // Temporarily add it to the document for measurement.
+    this.svg.appendChild(txt);
+
+    let bbox = txt.getBBox();
+    if (this.ie && text !== '' && this.attributes['font-style'] === 'italic') {
+      bbox = this.ieMeasureTextFix(bbox, text);
+    }
+
+    this.svg.removeChild(txt);
+    return bbox;
+  }
+
+  ieMeasureTextFix(bbox) {
+    // Internet Explorer over-pads text in italics,
+    // resulting in giant width estimates for measureText.
+    // To fix this, we use this formula, tested against
+    // ie 11:
+    // overestimate (in pixels) = FontSize(in pt) * 1.196 + 1.96
+    // And then subtract the overestimate from calculated width.
+
+    const fontSize = Number(this.fontSize);
+    const m = 1.196;
+    const b = 1.9598;
+    const widthCorrection = (m * fontSize) + b;
+    const width = bbox.width - widthCorrection;
+    const height = bbox.height - 1.5;
+
+    // Get non-protected copy:
+    const box = {
+      x: bbox.x,
+      y: bbox.y,
+      width,
+      height,
+    };
+
+    return box;
+  }
+
+  fillText(text, x, y) {
+    if (!text || text.length <= 0) {
+      return;
+    }
+    const attributes = {};
+    Vex.Merge(attributes, this.attributes);
+    attributes.stroke = 'none';
+    attributes.x = x;
+    attributes.y = y;
+
+    const txt = this.create('text');
+    txt.textContent = text;
+    this.applyAttributes(txt, attributes);
+    this.add(txt);
+  }
+
+  save() {
+    // TODO(mmuthanna): State needs to be deep-copied.
+    this.state_stack.push({
+      state: {
+        'font-family': this.state['font-family'],
+        'font-weight': this.state['font-weight'],
+        'font-style': this.state['font-style'],
+        'font-size': this.state['font-size'],
+        scale: this.state.scale,
+      },
+      attributes: {
+        'font-family': this.attributes['font-family'],
+        'font-weight': this.attributes['font-weight'],
+        'font-style': this.attributes['font-style'],
+        'font-size': this.attributes['font-size'],
+        fill: this.attributes.fill,
+        stroke: this.attributes.stroke,
+        'stroke-width': this.attributes['stroke-width'],
+        'stroke-dasharray': this.attributes['stroke-dasharray'],
+      },
+      shadow_attributes: {
+        width: this.shadow_attributes.width,
+        color: this.shadow_attributes.color,
+      },
+      lineWidth: this.lineWidth,
+    });
+    return this;
+  }
+
+  restore() {
+    // TODO(0xfe): State needs to be deep-restored.
+    const state = this.state_stack.pop();
+    this.state['font-family'] = state.state['font-family'];
+    this.state['font-weight'] = state.state['font-weight'];
+    this.state['font-style'] = state.state['font-style'];
+    this.state['font-size'] = state.state['font-size'];
+    this.state.scale = state.state.scale;
+
+    this.attributes['font-family'] = state.attributes['font-family'];
+    this.attributes['font-weight'] = state.attributes['font-weight'];
+    this.attributes['font-style'] = state.attributes['font-style'];
+    this.attributes['font-size'] = state.attributes['font-size'];
+
+    this.attributes.fill = state.attributes.fill;
+    this.attributes.stroke = state.attributes.stroke;
+    this.attributes['stroke-width'] = state.attributes['stroke-width'];
+    this.attributes['stroke-dasharray'] = state.attributes['stroke-dasharray'];
+
+    this.shadow_attributes.width = state.shadow_attributes.width;
+    this.shadow_attributes.color = state.shadow_attributes.color;
+
+    this.lineWidth = state.lineWidth;
+    return this;
+  }
+}

+ 1 - 1
test/Common/FileIO/Xml_Test.ts

@@ -53,7 +53,7 @@ describe("XML interface", () => {
                 TestUtils.createOpenSheetMusicDisplay(div);
             openSheetMusicDisplay.load(score);
             done();
-        }).timeout(10000);
+        }).timeout(30000);
     }
 
     it("test IXmlElement", (done: Mocha.Done) => {

+ 47 - 0
test/MusicalScore/Graphical/VexFlow/VexFlowGraphicalNote_Test.ts

@@ -0,0 +1,47 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions */
+import { GraphicalMeasure } from "../../../../src/MusicalScore/Graphical/GraphicalMeasure";
+import { VexFlowGraphicalNote } from "../../../../src/MusicalScore/Graphical/VexFlow/VexFlowGraphicalNote";
+import { OpenSheetMusicDisplay } from "../../../../src/OpenSheetMusicDisplay/OpenSheetMusicDisplay";
+import { TestUtils } from "../../../Util/TestUtils";
+
+describe("VexFlow GraphicalNote", () => {
+    it("Can get SVG elements for note, stem and beam", (done: Mocha.Done) => {
+        //const url: string = "base/test/data/test_rest_positioning_8th_quarter.musicxml"; // doesn't work, works for Mozart Clarinet Quintet
+        const score: Document = TestUtils.getScore("test_beam_svg_double.musicxml");
+        // sample should start with a beamed 8th note, and be simple.
+        const div: HTMLElement = TestUtils.getDivElement(document);
+        const osmd: OpenSheetMusicDisplay = TestUtils.createOpenSheetMusicDisplay(div);
+        // we need this way of creating the score to get the SVG elements, doesn't work with creating MusicSheet by hand
+        osmd.load(score).then(
+            (_: {}) => {
+                 osmd.render();
+                 const gm: GraphicalMeasure = osmd.GraphicSheet.findGraphicalMeasure(0, 0);
+                 const note1: VexFlowGraphicalNote = (gm.staffEntries[0].graphicalVoiceEntries[0].notes[0] as VexFlowGraphicalNote);
+                 const noteSVG: SVGGElement = note1.getSVGGElement();
+                 chai.expect(noteSVG).to.not.be.null;
+                 chai.expect(noteSVG).to.not.be.undefined;
+                 // const noteSVGId: string = "vf-" + firstNote.getSVGId();
+                 // const noteSVG: HTMLElement = document.getElementById(noteSVGId);
+                 //const stemSVGId: string = noteSVGId + "-stem";
+                 //const stemSVG: HTMLElement = document.getElementById(stemSVGId);
+                 const stemSVG: HTMLElement = note1.getStemSVG();
+                 chai.expect(stemSVG).to.not.be.null;
+                 chai.expect(stemSVG).to.not.be.undefined;
+                 // const beamSVGId: string = noteSVGId + "-beam";
+                 // const beamSVG: HTMLElement = document.getElementById(beamSVGId);
+                 const beamSVGs: HTMLElement[] = note1.getBeamSVGs();
+                 chai.expect(beamSVGs.length).to.equal(1); // 8th beam start. (16th beam starts on note2)
+                 chai.expect(beamSVGs[0]).to.not.be.null;
+                 chai.expect(beamSVGs[0]).to.not.be.undefined;
+                 const note2: VexFlowGraphicalNote = (gm.staffEntries[1].graphicalVoiceEntries[0].notes[0] as VexFlowGraphicalNote);
+                 chai.expect(note2.getBeamSVGs().length).to.equal(1); // start of 16th beam
+                 const note3: VexFlowGraphicalNote = (gm.staffEntries[2].graphicalVoiceEntries[0].notes[0] as VexFlowGraphicalNote);
+                 chai.expect(note3.getBeamSVGs().length).to.equal(0); // end of 16th beam
+                 const note4: VexFlowGraphicalNote = (gm.staffEntries[3].graphicalVoiceEntries[0].notes[0] as VexFlowGraphicalNote);
+                 chai.expect(note4.getBeamSVGs().length).to.equal(2); // 16th beams start
+                 done();
+            },
+            done
+        );
+     });
+});

+ 2 - 18
test/MusicalScore/Graphical/VexFlow/VexFlowMeasure_Test.ts

@@ -7,13 +7,12 @@ import {VexFlowMusicSheetCalculator} from "../../../../src/MusicalScore/Graphica
 import {TestUtils} from "../../../Util/TestUtils";
 import {SourceMeasure} from "../../../../src/MusicalScore/VoiceData/SourceMeasure";
 import {SourceStaffEntry} from "../../../../src/MusicalScore/VoiceData/SourceStaffEntry";
-import {GraphicalMeasure} from "../../../../src/MusicalScore/Graphical/GraphicalMeasure";
 import {MusicSheetCalculator} from "../../../../src/MusicalScore/Graphical/MusicSheetCalculator";
 import {EngravingRules} from "../../../../src/MusicalScore/Graphical/EngravingRules";
 
 describe("VexFlow Measure", () => {
 
-   it("GraphicalMusicSheet", (done: Mocha.Done) => {
+   it("Can create GraphicalMusicSheet", (done: Mocha.Done) => {
       const path: string = "MuzioClementi_SonatinaOpus36No1_Part1.xml";
       const score: Document = TestUtils.getScore(path);
       chai.expect(score).to.not.be.undefined;
@@ -28,22 +27,7 @@ describe("VexFlow Measure", () => {
       done();
    });
 
-   it("Simple Measure", (done: Mocha.Done) => {
-      const sheet: MusicSheet = new MusicSheet();
-      sheet.Rules = new EngravingRules();
-      const measure: SourceMeasure = new SourceMeasure(1, sheet.Rules);
-      sheet.addMeasure(measure);
-      const calc: MusicSheetCalculator = new VexFlowMusicSheetCalculator(sheet.Rules);
-      const gms: GraphicalMusicSheet = new GraphicalMusicSheet(sheet, calc);
-      chai.expect(gms.MeasureList.length).to.equal(1);
-      chai.expect(gms.MeasureList[0].length).to.equal(1);
-      const gm: GraphicalMeasure = gms.MeasureList[0][0];
-      // console.log(gm);
-      chai.expect(gm).to.not.be.undefined; // at least necessary for linter so that variable is not unused
-      done();
-   });
-
-   it("Empty Measure", (done: Mocha.Done) => {
+   it("Can have a single empty Measure", (done: Mocha.Done) => {
       const sheet: MusicSheet = new MusicSheet();
       sheet.Rules = new EngravingRules();
       const measure: SourceMeasure = new SourceMeasure(1, sheet.Rules);

+ 1 - 1
test/MusicalScore/ScoreCalculation/MusicSheetCalculator_Test.ts

@@ -17,7 +17,7 @@ describe("Music Sheet Calculator", () => {
     let sheet: MusicSheet;
 
     it("calculates music sheet", (done: Mocha.Done) => {
-        this.timeout = 10000;
+        // this.timeout = 10000;
         MusicSheetCalculator.TextMeasurer = new VexFlowTextMeasurer(new EngravingRules());
         // Load the XML file
         const xml: Document = TestUtils.getScore(filename);

+ 1 - 1
test/Util/DiffImages_Test_Experimental.ts

@@ -58,6 +58,6 @@ describe("GeneratePNGImages", () => {
             //fs.writeFileSync(fileName, imageBuffer, { encoding: "base64" });
 
             done();
-            }).timeout(10000);
+            }).timeout(30000);
     }
 });

+ 1 - 1
test/Util/generateDiffImagesPuppeteerLocalhost.js

@@ -8,7 +8,7 @@
   npm i puppeteer --save-dev
   (will download ~100MB for Chromium)
 
-  This script is made obsolete by the ~2x faster generateImages_browserless.js,
+  This script is made obsolete by the ~2x faster generateImages_browserless.mjs,
   but may be useful for comparison.
 
   inspired by Vexflow's generate_png_images and vexflow-tests.js

+ 399 - 0
test/Util/generateImages_browserless.mjs

@@ -0,0 +1,399 @@
+import Blob from "cross-blob";
+import FS from "fs";
+import jsdom from "jsdom";
+import OSMD from "../../build/opensheetmusicdisplay.min.js"; // window needs to be available before we can require OSMD
+/*
+  Render each OSMD sample, grab the generated images, and
+  dump them into a local directory as PNG or SVG files.
+
+  inspired by Vexflow's generate_png_images and vexflow-tests.js
+
+  This can be used to generate PNGs or SVGs from OSMD without a browser.
+  It's also used with the visual regression test system (using PNGs) in
+  `tools/visual_regression.sh`
+  (see package.json, used with npm run generate:blessed and generate:current, then test:visual).
+
+  Note: this script needs to "fake" quite a few browser elements, like window, document,
+  and a Canvas HTMLElement (for PNG) or the DOM (for SVG)   ,
+  which otherwise are missing in pure nodejs, causing errors in OSMD.
+  For PNG it needs the canvas package installed.
+  There are also some hacks needed to set the container size (offsetWidth) correctly.
+
+  Otherwise you'd need to run a headless browser, which is way slower,
+  see the semi-obsolete generateDiffImagesPuppeteerLocalhost.js
+*/
+
+function sleep (ms) {
+    return new Promise((resolve) => {
+        setTimeout(resolve, ms);
+    });
+}
+
+// global variables
+//   (without these being global, we'd have to pass many of these values to the generateSampleImage function)
+// eslint-disable-next-line prefer-const
+let [osmdBuildDir, sampleDir, imageDir, imageFormat, pageWidth, pageHeight, filterRegex, mode, debugSleepTimeString] = process.argv.slice(2, 11);
+if (!osmdBuildDir || !sampleDir || !imageDir || (imageFormat !== "png" && imageFormat !== "svg")) {
+    console.log("usage: " +
+        // eslint-disable-next-line max-len
+        "node test/Util/generateImages_browserless.mjs osmdBuildDir sampleDirectory imageDirectory svg|png [width|0] [height|0] [filterRegex|all|allSmall] [--debug|--osmdtesting] [debugSleepTime]");
+    console.log("  (use pageWidth and pageHeight 0 to not divide the rendering into pages (endless page))");
+    console.log('  (use "all" to skip filterRegex parameter. "allSmall" with --osmdtesting skips two huge OSMD samples that take forever to render)');
+    console.log("example: node test/Util/generateImages_browserless.mjs ../../build ./test/data/ ./export png 210 297 allSmall --debug 5000");
+    console.log("Error: need osmdBuildDir, sampleDir, imageDir and svg|png arguments. Exiting.");
+    process.exit(1);
+}
+let pageFormat;
+
+if (!mode) {
+    mode = "";
+}
+if (imageFormat !== "svg") {
+    imageFormat = "png";
+}
+
+// let OSMD; // can only be required once window was simulated
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+
+async function init () {
+    console.log("[OSMD.generateImages] init");
+
+    const osmdTestingMode = mode.includes("osmdtesting"); // can also be --debugosmdtesting
+    const osmdTestingSingleMode = mode.includes("osmdtestingsingle");
+    const DEBUG = mode.startsWith("--debug");
+    // const debugSleepTime = Number.parseInt(process.env.GENERATE_DEBUG_SLEEP_TIME) || 0; // 5000 works for me [sschmidTU]
+    if (DEBUG) {
+        // console.log(' (note that --debug slows down the script by about 0.3s per file, through logging)')
+        const debugSleepTimeMs = Number.parseInt(debugSleepTimeString, 10);
+        if (debugSleepTimeMs > 0) {
+            console.log("debug sleep time: " + debugSleepTimeString);
+            await sleep(Number.parseInt(debugSleepTimeMs, 10));
+            // [VSCode] apparently this is necessary for the debugger to attach itself in time before the program closes.
+            // sometimes this is not enough, so you may have to try multiple times or increase the sleep timer. Unfortunately debugging nodejs isn't easy.
+        }
+    }
+    debug("sampleDir: " + sampleDir, DEBUG);
+    debug("imageDir: " + imageDir, DEBUG);
+    debug("imageFormat: " + imageFormat, DEBUG);
+
+    pageFormat = "Endless";
+    pageWidth = Number.parseInt(pageWidth, 10);
+    pageHeight = Number.parseInt(pageHeight, 10);
+    const endlessPage = !(pageHeight > 0 && pageWidth > 0);
+    if (!endlessPage) {
+        pageFormat = `${pageWidth}x${pageHeight}`;
+    }
+
+    // ---- hacks to fake Browser elements OSMD and Vexflow need, like window, document, and a canvas HTMLElement ----
+    // eslint-disable-next-line @typescript-eslint/no-var-requires
+    const dom = new jsdom.JSDOM("<!DOCTYPE html></html>");
+    // eslint-disable-next-line no-global-assign
+    // window = dom.window;
+    // eslint-disable-next-line no-global-assign
+    // document = dom.window.document;
+
+    // eslint-disable-next-line no-global-assign
+    global.window = dom.window;
+    // eslint-disable-next-line no-global-assign
+    global.document = window.document;
+    //window.console = console; // probably does nothing
+    global.HTMLElement = window.HTMLElement;
+    global.HTMLAnchorElement = window.HTMLAnchorElement;
+    global.XMLHttpRequest = window.XMLHttpRequest;
+    global.DOMParser = window.DOMParser;
+    global.Node = window.Node;
+    if (imageFormat === "png") {
+        global.Canvas = window.Canvas;
+    }
+
+    // fix Blob not found (to support external modules like is-blob)
+    global.Blob = Blob;
+
+    const div = document.createElement("div");
+    div.id = "browserlessDiv";
+    document.body.appendChild(div);
+    // const canvas = document.createElement('canvas')
+    // div.canvas = document.createElement('canvas')
+
+    const zoom = 1.0;
+    // width of the div / PNG generated
+    let width = pageWidth * zoom;
+    // TODO sometimes the width is way too small for the score, may need to adjust zoom.
+    if (endlessPage) {
+        width = 1440;
+    }
+    let height = pageHeight;
+    if (endlessPage) {
+        height = 32767;
+    }
+    div.width = width;
+    div.height = height;
+    // div.offsetWidth = width; // doesn't work, offsetWidth is always 0 from this. see below
+    // div.clientWidth = width;
+    // div.clientHeight = height;
+    // div.scrollHeight = height;
+    // div.scrollWidth = width;
+    div.setAttribute("width", width);
+    div.setAttribute("height", height);
+    div.setAttribute("offsetWidth", width);
+    // debug('div.offsetWidth: ' + div.offsetWidth, DEBUG) // 0 here, set correctly later
+    // debug('div.height: ' + div.height, DEBUG)
+
+    // hack: set offsetWidth reliably
+    Object.defineProperties(window.HTMLElement.prototype, {
+        offsetLeft: {
+            get: function () { return parseFloat(window.getComputedStyle(this).marginTop) || 0; }
+        },
+        offsetTop: {
+            get: function () { return parseFloat(window.getComputedStyle(this).marginTop) || 0; }
+        },
+        offsetHeight: {
+            get: function () { return height; }
+        },
+        offsetWidth: {
+            get: function () { return width; }
+        }
+    });
+    debug("div.offsetWidth: " + div.offsetWidth, DEBUG);
+    debug("div.height: " + div.height, DEBUG);
+    // ---- end browser hacks (hopefully) ----
+
+    // load globally
+
+    // Create the image directory if it doesn't exist.
+    FS.mkdirSync(imageDir, { recursive: true });
+
+    const sampleDirFilenames = FS.readdirSync(sampleDir);
+    let samplesToProcess = []; // samples we want to process/generate pngs of, excluding the filtered out files/filenames
+    for (const sampleFilename of sampleDirFilenames) {
+        if (osmdTestingMode && filterRegex === "allSmall") {
+            if (sampleFilename.match("^(Actor)|(Gounod)")) { // TODO maybe filter by file size instead
+                debug("filtering big file: " + sampleFilename, DEBUG);
+                continue;
+            }
+        }
+        // eslint-disable-next-line no-useless-escape
+        if (sampleFilename.match("^.*(\.xml)|(\.musicxml)|(\.mxl)$")) {
+            // console.log('found musicxml/mxl: ' + sampleFilename)
+            samplesToProcess.push(sampleFilename);
+        } else {
+            debug("discarded file/directory: " + sampleFilename, DEBUG);
+        }
+    }
+
+    // filter samples to process by regex if given
+    if (filterRegex && filterRegex !== "" && filterRegex !== "all" && !(osmdTestingMode && filterRegex === "allSmall")) {
+        debug("filtering samples for regex: " + filterRegex, DEBUG);
+        samplesToProcess = samplesToProcess.filter((filename) => filename.match(filterRegex));
+        debug(`found ${samplesToProcess.length} matches: `, DEBUG);
+        for (let i = 0; i < samplesToProcess.length; i++) {
+            debug(samplesToProcess[i], DEBUG);
+        }
+    }
+
+    const backend = imageFormat === "png" ? "canvas" : "svg";
+    const osmdInstance = new OSMD.OpenSheetMusicDisplay(div, {
+        autoResize: false,
+        backend: backend,
+        pageBackgroundColor: "#FFFFFF",
+        pageFormat: pageFormat
+        // defaultFontFamily: 'Arial',
+        // drawTitle: false
+    });
+    // for more options check OSMDOptions.ts
+
+    // you can set finer-grained rendering/engraving settings in EngravingRules:
+    // osmdInstance.EngravingRules.TitleTopDistance = 5.0 // 5.0 is default
+    //   (unless in osmdTestingMode, these will be reset with drawingParameters default)
+    // osmdInstance.EngravingRules.PageTopMargin = 5.0 // 5 is default
+    // osmdInstance.EngravingRules.PageBottomMargin = 5.0 // 5 is default. <5 can cut off scores that extend in the last staffline
+    // note that for now the png and canvas will still have the height given in the script argument,
+    //   so even with a margin of 0 the image will be filled to the full height.
+    // osmdInstance.EngravingRules.PageLeftMargin = 5.0 // 5 is default
+    // osmdInstance.EngravingRules.PageRightMargin = 5.0 // 5 is default
+    // osmdInstance.EngravingRules.MetronomeMarkXShift = -8; // -6 is default
+    // osmdInstance.EngravingRules.DistanceBetweenVerticalSystemLines = 0.15; // 0.35 is default
+    // for more options check EngravingRules.ts (though not all of these are meant and fully supported to be changed at will)
+
+    if (DEBUG) {
+        osmdInstance.setLogLevel("debug");
+        // console.log(`osmd PageFormat: ${osmdInstance.EngravingRules.PageFormat.width}x${osmdInstance.EngravingRules.PageFormat.height}`)
+        console.log(`osmd PageFormat idString: ${osmdInstance.EngravingRules.PageFormat.idString}`);
+        console.log("PageHeight: " + osmdInstance.EngravingRules.PageHeight);
+    } else {
+        osmdInstance.setLogLevel("info"); // doesn't seem to work, log.debug still logs
+    }
+
+    debug("[OSMD.generateImages] starting loop over samples, saving images to " + imageDir, DEBUG);
+    for (let i = 0; i < samplesToProcess.length; i++) {
+        const sampleFilename = samplesToProcess[i];
+        debug("sampleFilename: " + sampleFilename, DEBUG);
+
+        await generateSampleImage(sampleFilename, sampleDir, osmdInstance, osmdTestingMode, {}, DEBUG);
+
+        if (osmdTestingMode && !osmdTestingSingleMode && sampleFilename.startsWith("Beethoven") && sampleFilename.includes("Geliebte")) {
+            // generate one more testing image with skyline and bottomline. (startsWith 'Beethoven' don't catch the function test)
+            await generateSampleImage(sampleFilename, sampleDir, osmdInstance, osmdTestingMode, {skyBottomLine: true}, DEBUG);
+            // generate one more testing image with GraphicalNote positions
+            await generateSampleImage(sampleFilename, sampleDir, osmdInstance, osmdTestingMode, {boundingBoxes: "VexFlowGraphicalNote"}, DEBUG);
+        }
+    }
+
+    console.log("[OSMD.generateImages] done, exiting.");
+}
+
+// eslint-disable-next-line
+// let maxRss = 0, maxRssFilename = '' // to log memory usage (debug)
+async function generateSampleImage (sampleFilename, directory, osmdInstance, osmdTestingMode,
+    options = {}, DEBUG = false) {
+    const samplePath = directory + "/" + sampleFilename;
+    let loadParameter = FS.readFileSync(samplePath);
+
+    if (sampleFilename.endsWith(".mxl")) {
+        loadParameter = await OSMD.MXLHelper.MXLtoXMLstring(loadParameter);
+    } else {
+        loadParameter = loadParameter.toString();
+    }
+    // console.log('loadParameter: ' + loadParameter)
+    // console.log('typeof loadParameter: ' + typeof loadParameter)
+
+    // set sample-specific options for OSMD visual regression testing
+    let includeSkyBottomLine = false;
+    let drawBoundingBoxString;
+    if (osmdTestingMode) {
+        const isFunctionTestAutobeam = sampleFilename.startsWith("OSMD_function_test_autobeam");
+        const isFunctionTestAutoColoring = sampleFilename.startsWith("OSMD_function_test_auto-custom-coloring");
+        const isFunctionTestSystemAndPageBreaks = sampleFilename.startsWith("OSMD_Function_Test_System_and_Page_Breaks");
+        const isFunctionTestDrawingRange = sampleFilename.startsWith("OSMD_function_test_measuresToDraw_");
+        const defaultOrCompactTightMode = sampleFilename.startsWith("OSMD_Function_Test_Container_height") ? "compacttight" : "default";
+        const isTestFlatBeams = sampleFilename.startsWith("test_drum_tuplet_beams");
+        const isTestEndClefStaffEntryBboxes = sampleFilename.startsWith("test_end_measure_clefs_staffentry_bbox");
+        if (isTestEndClefStaffEntryBboxes) {
+            drawBoundingBoxString = "VexFlowStaffEntry";
+        } else {
+            drawBoundingBoxString = options.boundingBoxes; // undefined is also a valid value: no bboxes
+        }
+        osmdInstance.setOptions({
+            autoBeam: isFunctionTestAutobeam, // only set to true for function test autobeam
+            coloringMode: isFunctionTestAutoColoring ? 2 : 0,
+            // eslint-disable-next-line max-len
+            coloringSetCustom: isFunctionTestAutoColoring ? ["#d82c6b", "#F89D15", "#FFE21A", "#4dbd5c", "#009D96", "#43469d", "#76429c", "#ff0000"] : undefined,
+            colorStemsLikeNoteheads: isFunctionTestAutoColoring,
+            drawingParameters: defaultOrCompactTightMode, // note: default resets all EngravingRules. could be solved differently
+            drawFromMeasureNumber: isFunctionTestDrawingRange ? 9 : 1,
+            drawUpToMeasureNumber: isFunctionTestDrawingRange ? 12 : Number.MAX_SAFE_INTEGER,
+            newSystemFromXML: isFunctionTestSystemAndPageBreaks,
+            newPageFromXML: isFunctionTestSystemAndPageBreaks,
+            pageBackgroundColor: "#FFFFFF", // reset by drawingparameters default
+            pageFormat: pageFormat // reset by drawingparameters default
+        });
+        includeSkyBottomLine = options.skyBottomLine ? options.skyBottomLine : false; // apparently es6 doesn't have ?? operator
+        osmdInstance.drawSkyLine = includeSkyBottomLine; // if includeSkyBottomLine, draw skyline and bottomline, else not
+        osmdInstance.drawBottomLine = includeSkyBottomLine;
+        osmdInstance.setDrawBoundingBox(drawBoundingBoxString, false); // false: don't render (now). also (re-)set if undefined!
+        if (isTestFlatBeams) {
+            osmdInstance.EngravingRules.FlatBeams = true;
+            // osmdInstance.EngravingRules.FlatBeamOffset = 30;
+            osmdInstance.EngravingRules.FlatBeamOffset = 10;
+            osmdInstance.EngravingRules.FlatBeamOffsetPerBeam = 10;
+        } else {
+            osmdInstance.EngravingRules.FlatBeams = false;
+        }
+    }
+
+    try {
+        await osmdInstance.load(loadParameter); // if using load.then() without await, memory will not be freed up between renders
+    } catch (ex) {
+        console.log("couldn't load sample " + sampleFilename + ", skipping. Error: \n" + ex);
+        return;
+    }
+    debug("xml loaded", DEBUG);
+    try {
+        osmdInstance.render();
+        // there were reports that await could help here, but render isn't a synchronous function, and it seems to work. see #932
+    } catch (ex) {
+        console.log("renderError: " + ex);
+    }
+    debug("rendered", DEBUG);
+
+    const markupStrings = []; // svg
+    const dataUrls = []; // png
+    let canvasImage;
+
+    for (let pageNumber = 1; pageNumber < Number.POSITIVE_INFINITY; pageNumber++) {
+        if (imageFormat === "png") {
+            canvasImage = document.getElementById("osmdCanvasVexFlowBackendCanvas" + pageNumber);
+            if (!canvasImage) {
+                break;
+            }
+            if (!canvasImage.toDataURL) {
+                console.log(`error: could not get canvas image for page ${pageNumber} for file: ${sampleFilename}`);
+                break;
+            }
+            dataUrls.push(canvasImage.toDataURL());
+        } else if (imageFormat === "svg") {
+            const svgElement = document.getElementById("osmdSvgPage" + pageNumber);
+            if (!svgElement) {
+                break;
+            }
+            // The important xmlns attribute is not serialized unless we set it here
+            svgElement.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+            markupStrings.push(svgElement.outerHTML);
+        }
+    }
+
+    for (let pageIndex = 0; pageIndex < Math.max(dataUrls.length, markupStrings.length); pageIndex++) {
+        const pageNumberingString = `${pageIndex + 1}`;
+        const skybottomlineString = includeSkyBottomLine ? "skybottomline_" : "";
+        const graphicalNoteBboxesString = drawBoundingBoxString ? "bbox" + drawBoundingBoxString + "_" : "";
+        // pageNumberingString = dataUrls.length > 0 ? pageNumberingString : '' // don't put '_1' at the end if only one page. though that may cause more work
+        const pageFilename = `${imageDir}/${sampleFilename}_${skybottomlineString}${graphicalNoteBboxesString}${pageNumberingString}.${imageFormat}`;
+
+        if (imageFormat === "png") {
+            const dataUrl = dataUrls[pageIndex];
+            if (!dataUrl || !dataUrl.split) {
+                console.log(`error: could not get dataUrl (imageData) for page ${pageIndex + 1} of sample: ${sampleFilename}`);
+                continue;
+            }
+            const imageData = dataUrl.split(";base64,").pop();
+            const imageBuffer = Buffer.from(imageData, "base64");
+
+            debug("got image data, saving to: " + pageFilename, DEBUG);
+            FS.writeFileSync(pageFilename, imageBuffer, { encoding: "base64" });
+        } else if (imageFormat === "svg") {
+            const markup = markupStrings[pageIndex];
+            if (!markup) {
+                console.log(`error: could not get markup (SVG data) for page ${pageIndex + 1} of sample: ${sampleFilename}`);
+                continue;
+            }
+
+            debug("got svg markup data, saving to: " + pageFilename, DEBUG);
+            FS.writeFileSync(pageFilename, markup, { encoding: "utf-8" });
+        }
+
+        // debug: log memory usage
+        // const usage = process.memoryUsage()
+        // for (const entry of Object.entries(usage)) {
+        //     if (entry[0] === 'rss') {
+        //         if (entry[1] > maxRss) {
+        //             maxRss = entry[1]
+        //             maxRssFilename = pageFilename
+        //         }
+        //     }
+        //     console.log(entry[0] + ': ' + entry[1] / (1024 * 1024) + 'mb')
+        // }
+        // console.log('maxRss: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
+    }
+    // console.log('maxRss total: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
+
+    // await sleep(5000)
+    // }) // end read file
+}
+
+function debug (msg, debugEnabled) {
+    if (debugEnabled) {
+        console.log(msg);
+    }
+}
+
+init();

+ 163 - 0
test/data/test_beam_svg_double.musicxml

@@ -0,0 +1,163 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <work>
+    <work-title>test_beam_svg_double</work-title>
+    </work>
+  <identification>
+    <encoding>
+      <software>MuseScore 3.6.2</software>
+      <encoding-date>2022-01-14</encoding-date>
+      <supports element="accidental" type="yes"/>
+      <supports element="beam" type="yes"/>
+      <supports element="print" attribute="new-page" type="yes" value="yes"/>
+      <supports element="print" attribute="new-system" type="yes" value="yes"/>
+      <supports element="stem" type="yes"/>
+      </encoding>
+    </identification>
+  <defaults>
+    <scaling>
+      <millimeters>6.99911</millimeters>
+      <tenths>40</tenths>
+      </scaling>
+    <page-layout>
+      <page-height>1696.94</page-height>
+      <page-width>1200.48</page-width>
+      <page-margins type="even">
+        <left-margin>85.7252</left-margin>
+        <right-margin>85.7252</right-margin>
+        <top-margin>85.7252</top-margin>
+        <bottom-margin>85.7252</bottom-margin>
+        </page-margins>
+      <page-margins type="odd">
+        <left-margin>85.7252</left-margin>
+        <right-margin>85.7252</right-margin>
+        <top-margin>85.7252</top-margin>
+        <bottom-margin>85.7252</bottom-margin>
+        </page-margins>
+      </page-layout>
+    <word-font font-family="Edwin" font-size="10"/>
+    <lyric-font font-family="Edwin" font-size="10"/>
+    </defaults>
+  <credit page="1">
+    <credit-type>title</credit-type>
+    <credit-words default-x="600.24" default-y="1611.63" justify="center" valign="top" font-size="22">test_beam_svg_double</credit-words>
+    </credit>
+  <credit page="1">
+    <credit-type>composer</credit-type>
+    <credit-words default-x="1114.75" default-y="1511.63" justify="right" valign="top">Composer</credit-words>
+    </credit>
+  <part-list>
+    <score-part id="P1">
+      <part-name>Piano</part-name>
+      <part-abbreviation>Pno.</part-abbreviation>
+      <score-instrument id="P1-I1">
+        <instrument-name>Piano</instrument-name>
+        </score-instrument>
+      <midi-device id="P1-I1" port="1"></midi-device>
+      <midi-instrument id="P1-I1">
+        <midi-channel>1</midi-channel>
+        <midi-program>1</midi-program>
+        <volume>78.7402</volume>
+        <pan>0</pan>
+        </midi-instrument>
+      </score-part>
+    </part-list>
+  <part id="P1">
+    <measure number="1" width="469.82">
+      <print>
+        <system-layout>
+          <system-margins>
+            <left-margin>50.00</left-margin>
+            <right-margin>509.21</right-margin>
+            </system-margins>
+          <top-system-distance>170.00</top-system-distance>
+          </system-layout>
+        </print>
+      <attributes>
+        <divisions>4</divisions>
+        <key>
+          <fifths>0</fifths>
+          </key>
+        <time>
+          <beats>2</beats>
+          <beat-type>4</beat-type>
+          </time>
+        <clef>
+          <sign>G</sign>
+          <line>2</line>
+          </clef>
+        </attributes>
+      <note default-x="85.98" default-y="-30.00">
+        <pitch>
+          <step>G</step>
+          <alter>1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <accidental>sharp</accidental>
+        <stem>up</stem>
+        <beam number="1">begin</beam>
+        </note>
+      <note default-x="168.91" default-y="-25.00">
+        <pitch>
+          <step>A</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <beam number="1">continue</beam>
+        <beam number="2">begin</beam>
+        </note>
+      <note default-x="220.74" default-y="-20.00">
+        <pitch>
+          <step>B</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        </note>
+      <note default-x="272.58" default-y="-15.00">
+        <pitch>
+          <step>C</step>
+          <octave>5</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <stem>down</stem>
+        <beam number="1">begin</beam>
+        <beam number="2">begin</beam>
+        </note>
+      <note default-x="324.41" default-y="-10.00">
+        <pitch>
+          <step>D</step>
+          <octave>5</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <stem>down</stem>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        </note>
+      <note>
+        <rest/>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        </note>
+      <barline location="right">
+        <bar-style>light-heavy</bar-style>
+        </barline>
+      </measure>
+    </part>
+  </score-partwise>

+ 704 - 0
test/data/test_clef_end_measure.musicxml

@@ -0,0 +1,704 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <work>
+    <work-title>test_clef_end_measure</work-title>
+    </work>
+  <identification>
+    <encoding>
+      <software>MuseScore 3.6.2</software>
+      <encoding-date>2022-01-14</encoding-date>
+      <supports element="accidental" type="yes"/>
+      <supports element="beam" type="yes"/>
+      <supports element="print" attribute="new-page" type="yes" value="yes"/>
+      <supports element="print" attribute="new-system" type="yes" value="yes"/>
+      <supports element="stem" type="yes"/>
+      </encoding>
+    </identification>
+  <defaults>
+    <scaling>
+      <millimeters>7</millimeters>
+      <tenths>40</tenths>
+      </scaling>
+    <page-layout>
+      <page-height>1697.14</page-height>
+      <page-width>1200</page-width>
+      <page-margins type="even">
+        <left-margin>85.7143</left-margin>
+        <right-margin>85.7143</right-margin>
+        <top-margin>85.7143</top-margin>
+        <bottom-margin>85.7143</bottom-margin>
+        </page-margins>
+      <page-margins type="odd">
+        <left-margin>85.7143</left-margin>
+        <right-margin>85.7143</right-margin>
+        <top-margin>85.7143</top-margin>
+        <bottom-margin>85.7143</bottom-margin>
+        </page-margins>
+      </page-layout>
+    <word-font font-family="Edwin" font-size="10"/>
+    <lyric-font font-family="Edwin" font-size="10"/>
+    </defaults>
+  <credit page="1">
+    <credit-type>test_clef_end_measure</credit-type>
+    <credit-words default-x="600" default-y="1611.43" justify="center" valign="top" font-size="22">clef end measure test</credit-words>
+    </credit>
+  <part-list>
+    <score-part id="P1">
+      <part-name>Piano</part-name>
+      <part-abbreviation>Pno.</part-abbreviation>
+      <score-instrument id="P1-I1">
+        <instrument-name>Piano</instrument-name>
+        </score-instrument>
+      <midi-device id="P1-I1" port="1"></midi-device>
+      <midi-instrument id="P1-I1">
+        <midi-channel>1</midi-channel>
+        <midi-program>1</midi-program>
+        <volume>78.7402</volume>
+        <pan>0</pan>
+        </midi-instrument>
+      </score-part>
+    </part-list>
+  <part id="P1">
+    <measure number="1" width="525.53">
+      <print>
+        <system-layout>
+          <system-margins>
+            <left-margin>65.90</left-margin>
+            <right-margin>0.00</right-margin>
+            </system-margins>
+          <top-system-distance>170.00</top-system-distance>
+          </system-layout>
+        <staff-layout number="2">
+          <staff-distance>90.03</staff-distance>
+          </staff-layout>
+        </print>
+      <attributes>
+        <divisions>4</divisions>
+        <key>
+          <fifths>2</fifths>
+          </key>
+        <time>
+          <beats>4</beats>
+          <beat-type>4</beat-type>
+          </time>
+        <staves>2</staves>
+        <clef number="1">
+          <sign>G</sign>
+          <line>2</line>
+          </clef>
+        <clef number="2">
+          <sign>F</sign>
+          <line>4</line>
+          </clef>
+        </attributes>
+      <note>
+        <rest measure="yes"/>
+        <duration>16</duration>
+        <voice>1</voice>
+        <staff>1</staff>
+        </note>
+      <backup>
+        <duration>16</duration>
+        </backup>
+      <direction placement="above">
+        <direction-type>
+          <wedge type="crescendo" number="1" default-y="78.24"/>
+          </direction-type>
+        <staff>2</staff>
+        </direction>
+      <note default-x="113.27" default-y="-135.03">
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">begin</beam>
+        <beam number="2">begin</beam>
+        <notations>
+          <slur type="start" placement="above" number="1"/>
+          </notations>
+        </note>
+      <note default-x="142.58" default-y="-145.03">
+        <pitch>
+          <step>E</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        </note>
+      <note default-x="171.88" default-y="-135.03">
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        </note>
+      <note default-x="201.19" default-y="-120.03">
+        <pitch>
+          <step>C</step>
+          <alter>1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        </note>
+      <direction placement="above">
+        <direction-type>
+          <wedge type="stop" number="1"/>
+          </direction-type>
+        <staff>2</staff>
+        </direction>
+      <note default-x="230.50" default-y="-110.03">
+        <pitch>
+          <step>E</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">begin</beam>
+        <beam number="2">begin</beam>
+        </note>
+      <note default-x="259.80" default-y="-135.03">
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        </note>
+      <note default-x="289.11" default-y="-140.03">
+        <pitch>
+          <step>F</step>
+          <alter>1</alter>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        <beam number="2">continue</beam>
+        </note>
+      <direction placement="above">
+        <direction-type>
+          <wedge type="diminuendo" number="1" default-y="70.10"/>
+          </direction-type>
+        <staff>2</staff>
+        </direction>
+      <note default-x="318.42" default-y="-145.03">
+        <pitch>
+          <step>E</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        <notations>
+          <slur type="stop" number="1"/>
+          </notations>
+        </note>
+      <direction placement="above">
+        <direction-type>
+          <wedge type="stop" number="1"/>
+          </direction-type>
+        <staff>2</staff>
+        </direction>
+      <note default-x="334.82" default-y="-145.03">
+        <grace/>
+        <pitch>
+          <step>E</step>
+          <octave>3</octave>
+          </pitch>
+        <voice>5</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <notations>
+          <slur type="start" placement="above" number="1"/>
+          </notations>
+        </note>
+      <note default-x="353.75" default-y="-140.03">
+        <pitch>
+          <step>F</step>
+          <alter>1</alter>
+          <octave>3</octave>
+          </pitch>
+        <duration>4</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <notations>
+          <slur type="stop" number="1"/>
+          </notations>
+        </note>
+      <direction placement="above">
+        <direction-type>
+          <dynamics default-x="6.50" default-y="8.65" relative-y="15.00">
+            <p/>
+            </dynamics>
+          </direction-type>
+        <staff>2</staff>
+        <sound dynamics="54.44"/>
+        </direction>
+      <note default-x="418.23" default-y="-150.03">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <notations>
+          <slur type="start" placement="below" number="1"/>
+          <technical>
+            <fingering>1</fingering>
+            </technical>
+          </notations>
+        </note>
+      <note default-x="465.12" default-y="-10.00">
+        <pitch>
+          <step>D</step>
+          <octave>5</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>1</staff>
+        <notations>
+          <slur type="stop" number="1"/>
+          <technical>
+            <fingering placement="below">4</fingering>
+            </technical>
+          </notations>
+        </note>
+      <backup>
+        <duration>1</duration>
+        </backup>
+      <attributes>
+        <clef id="666" number="2">
+          <sign>G</sign>
+          <line>2</line>
+          </clef>
+        </attributes>
+      <forward>
+        <duration>1</duration>
+        </forward>
+      <backup>
+        <duration>16</duration>
+        </backup>
+      <note default-x="113.27" default-y="-145.03">
+        <pitch>
+          <step>E</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>6</voice>
+        <type>16th</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        <notations>
+          <articulations>
+            <staccato/>
+            </articulations>
+          </notations>
+        </note>
+      <forward>
+        <duration>3</duration>
+        </forward>
+      <note default-x="230.50" default-y="-165.03">
+        <pitch>
+          <step>A</step>
+          <octave>2</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>6</voice>
+        <type>16th</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        <notations>
+          <articulations>
+            <staccato/>
+            </articulations>
+          </notations>
+        </note>
+      <forward>
+        <duration>3</duration>
+        </forward>
+      <note default-x="353.75" default-y="-185.03">
+        <pitch>
+          <step>D</step>
+          <octave>2</octave>
+          </pitch>
+        <duration>4</duration>
+        <voice>6</voice>
+        <type>quarter</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        <notations>
+          <articulations>
+            <tenuto/>
+            </articulations>
+          </notations>
+        </note>
+      <note>
+        <rest/>
+        <duration>4</duration>
+        <voice>6</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+        </note>
+      </measure>
+    <measure number="2" width="437.14">
+      <direction placement="below">
+        <direction-type>
+          <words default-y="-40.00" relative-y="-25.00" font-style="italic">espress.</words>
+          </direction-type>
+        <staff>1</staff>
+        </direction>
+      <note default-x="13.00" default-y="-25.00">
+        <pitch>
+          <step>A</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <stem>up</stem>
+        <staff>1</staff>
+        <beam number="1">begin</beam>
+        <beam number="2">begin</beam>
+        <notations>
+          <slur type="start" placement="above" number="1"/>
+          <technical>
+            <fingering>1</fingering>
+            </technical>
+          </notations>
+        </note>
+      <note default-x="44.32" default-y="-20.00">
+        <pitch>
+          <step>B</step>
+          <alter>-1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>16th</type>
+        <accidental>flat</accidental>
+        <stem>up</stem>
+        <staff>1</staff>
+        <beam number="1">end</beam>
+        <beam number="2">end</beam>
+        </note>
+      <note default-x="75.65" default-y="-15.00">
+        <pitch>
+          <step>C</step>
+          <octave>5</octave>
+          </pitch>
+        <duration>4</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <accidental>natural</accidental>
+        <stem>down</stem>
+        <staff>1</staff>
+        </note>
+      <note default-x="175.89" default-y="-20.00">
+        <pitch>
+          <step>B</step>
+          <alter>-1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>1</staff>
+        <notations>
+          <slur type="stop" number="1"/>
+          </notations>
+        </note>
+      <note default-x="226.01" default-y="-20.00">
+        <pitch>
+          <step>B</step>
+          <alter>-1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>1</staff>
+        <notations>
+          <slur type="start" placement="above" number="1"/>
+          <technical>
+            <fingering>3</fingering>
+            </technical>
+          </notations>
+        </note>
+      <note default-x="276.13" default-y="-25.00">
+        <pitch>
+          <step>A</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>4</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        <staff>1</staff>
+        <notations>
+          <slur type="stop" number="1"/>
+          </notations>
+        </note>
+      <note default-x="376.37" default-y="-25.00">
+        <pitch>
+          <step>A</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>1</staff>
+        </note>
+      <backup>
+        <duration>16</duration>
+        </backup>
+      <note default-x="13.00" default-y="-175.03">
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">begin</beam>
+        </note>
+      <note default-x="13.00" default-y="-165.03">
+        <chord/>
+        <pitch>
+          <step>F</step>
+          <alter>1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="75.65" default-y="-175.03">
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        </note>
+      <note default-x="75.65" default-y="-160.03">
+        <chord/>
+        <pitch>
+          <step>G</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="125.77" default-y="-175.03">
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        </note>
+      <note default-x="125.77" default-y="-155.03">
+        <chord/>
+        <pitch>
+          <step>A</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="175.89" default-y="-175.03">
+        <pitch>
+          <step>D</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">end</beam>
+        </note>
+      <note default-x="175.89" default-y="-160.03">
+        <chord/>
+        <pitch>
+          <step>G</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="226.01" default-y="-180.03">
+        <pitch>
+          <step>C</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <accidental>natural</accidental>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">begin</beam>
+        </note>
+      <note default-x="226.01" default-y="-160.03">
+        <chord/>
+        <pitch>
+          <step>G</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="276.13" default-y="-180.03">
+        <pitch>
+          <step>C</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        </note>
+      <note default-x="276.13" default-y="-160.03">
+        <chord/>
+        <pitch>
+          <step>G</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="326.25" default-y="-180.03">
+        <pitch>
+          <step>C</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">continue</beam>
+        </note>
+      <note default-x="326.25" default-y="-165.03">
+        <chord/>
+        <pitch>
+          <step>F</step>
+          <alter>1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="376.37" default-y="-180.03">
+        <pitch>
+          <step>C</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        <beam number="1">end</beam>
+        </note>
+      <note default-x="376.37" default-y="-165.03">
+        <chord/>
+        <pitch>
+          <step>F</step>
+          <alter>1</alter>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <barline location="right">
+        <bar-style>light-heavy</bar-style>
+        </barline>
+      </measure>
+    </part>
+  </score-partwise>

+ 125 - 0
test/data/test_note_alignment_long_distance_notes.musicxml

@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <work>
+    <work-title>test note alignment long distance</work-title>
+    </work>
+  <identification>
+    <creator type="composer">Composer</creator>
+    <encoding>
+      <software>MuseScore 3.6.2</software>
+      <encoding-date>2022-01-08</encoding-date>
+      <supports element="accidental" type="yes"/>
+      <supports element="beam" type="yes"/>
+      <supports element="print" attribute="new-page" type="yes" value="yes"/>
+      <supports element="print" attribute="new-system" type="yes" value="yes"/>
+      <supports element="stem" type="yes"/>
+      </encoding>
+    </identification>
+  <defaults>
+    <scaling>
+      <millimeters>6.99911</millimeters>
+      <tenths>40</tenths>
+      </scaling>
+    <page-layout>
+      <page-height>1696.94</page-height>
+      <page-width>1200.48</page-width>
+      <page-margins type="even">
+        <left-margin>85.7252</left-margin>
+        <right-margin>85.7252</right-margin>
+        <top-margin>85.7252</top-margin>
+        <bottom-margin>85.7252</bottom-margin>
+        </page-margins>
+      <page-margins type="odd">
+        <left-margin>85.7252</left-margin>
+        <right-margin>85.7252</right-margin>
+        <top-margin>85.7252</top-margin>
+        <bottom-margin>85.7252</bottom-margin>
+        </page-margins>
+      </page-layout>
+    <word-font font-family="Edwin" font-size="10"/>
+    <lyric-font font-family="Edwin" font-size="10"/>
+    </defaults>
+  <credit page="1">
+    <credit-type>title</credit-type>
+    <credit-words default-x="600.242" default-y="1611.21" justify="center" valign="top" font-size="22">Title</credit-words>
+    </credit>
+  <credit page="1">
+    <credit-type>composer</credit-type>
+    <credit-words default-x="1114.76" default-y="1511.21" justify="right" valign="bottom">Composer</credit-words>
+    </credit>
+  <part-list>
+    <score-part id="P1">
+      <part-name>Piano</part-name>
+      <part-abbreviation>Pno.</part-abbreviation>
+      <score-instrument id="P1-I1">
+        <instrument-name>Piano</instrument-name>
+        </score-instrument>
+      <midi-device id="P1-I1" port="1"></midi-device>
+      <midi-instrument id="P1-I1">
+        <midi-channel>1</midi-channel>
+        <midi-program>1</midi-program>
+        <volume>78.7402</volume>
+        <pan>0</pan>
+        </midi-instrument>
+      </score-part>
+    </part-list>
+  <part id="P1">
+    <measure number="1" width="340.16">
+      <print>
+        <system-layout>
+          <system-margins>
+            <left-margin>50.00</left-margin>
+            <right-margin>638.55</right-margin>
+            </system-margins>
+          <top-system-distance>170.00</top-system-distance>
+          </system-layout>
+        </print>
+      <attributes>
+        <divisions>1</divisions>
+        <key>
+          <fifths>0</fifths>
+          </key>
+        <time>
+          <beats>4</beats>
+          <beat-type>4</beat-type>
+          </time>
+        <clef>
+          <sign>G</sign>
+          <line>2</line>
+          </clef>
+        </attributes>
+      <note default-x="80.72" default-y="-40.00">
+        <pitch>
+          <step>E</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>4</duration>
+        <voice>1</voice>
+        <type>whole</type>
+        </note>
+      <backup>
+        <duration>4</duration>
+        </backup>
+      <note default-x="82.64" default-y="-10.00">
+        <pitch>
+          <step>D</step>
+          <octave>5</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>2</voice>
+        <type>half</type>
+        <stem>up</stem>
+        </note>
+      <note>
+        <rest/>
+        <duration>2</duration>
+        <voice>2</voice>
+        <type>half</type>
+        </note>
+      <barline location="right">
+        <bar-style>light-heavy</bar-style>
+        </barline>
+      </measure>
+    </part>
+  </score-partwise>

+ 179 - 0
test/data/test_note_overlap_staggering_whole_eighths.musicxml

@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding='UTF-8' standalone='no' ?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.0">
+ <work>
+  <work-title>test note overlap whole+eighths</work-title>
+ </work>
+ <identification>
+  <encoding>
+   <encoding-date>2021-11-17</encoding-date>
+   <software>Sibelius 19.5.0</software>
+   <software>Direct export, not from Dolet</software>
+   <encoding-description>Sibelius / MusicXML 3.0</encoding-description>
+   <supports element="print" type="yes" value="yes" attribute="new-system" />
+   <supports element="print" type="yes" value="yes" attribute="new-page" />
+   <supports element="accidental" type="yes" />
+   <supports element="beam" type="yes" />
+   <supports element="stem" type="yes" />
+  </encoding>
+ </identification>
+ <defaults>
+  <scaling>
+   <millimeters>215.9</millimeters>
+   <tenths>1233</tenths>
+  </scaling>
+  <page-layout>
+   <page-height>1596</page-height>
+   <page-width>1233</page-width>
+   <page-margins type="both">
+    <left-margin>85</left-margin>
+    <right-margin>85</right-margin>
+    <top-margin>85</top-margin>
+    <bottom-margin>85</bottom-margin>
+   </page-margins>
+  </page-layout>
+  <system-layout>
+   <system-margins>
+    <left-margin>63</left-margin>
+    <right-margin>0</right-margin>
+   </system-margins>
+   <system-distance>92</system-distance>
+  </system-layout>
+  <appearance>
+   <line-width type="stem">0.9375</line-width>
+   <line-width type="beam">5</line-width>
+   <line-width type="staff">0.9375</line-width>
+   <line-width type="light barline">1.5625</line-width>
+   <line-width type="heavy barline">5</line-width>
+   <line-width type="leger">1.5625</line-width>
+   <line-width type="ending">1.5625</line-width>
+   <line-width type="wedge">1.25</line-width>
+   <line-width type="enclosure">0.9375</line-width>
+   <line-width type="tuplet bracket">1.25</line-width>
+   <line-width type="bracket">5</line-width>
+   <line-width type="dashes">1.5625</line-width>
+   <line-width type="extend">0.9375</line-width>
+   <line-width type="octave shift">1.5625</line-width>
+   <line-width type="pedal">1.5625</line-width>
+   <line-width type="slur middle">1.5625</line-width>
+   <line-width type="slur tip">0.625</line-width>
+   <line-width type="tie middle">1.5625</line-width>
+   <line-width type="tie tip">0.625</line-width>
+   <note-size type="cue">75</note-size>
+   <note-size type="grace">60</note-size>
+  </appearance>
+  <music-font font-family="Opus Std" font-size="19.8425" />
+  <lyric-font font-family="Times New Roman" font-size="11.4715" />
+  <lyric-language xml:lang="en" />
+ </defaults>
+ <part-list>
+  <part-group type="start" number="1">
+   <group-symbol>brace</group-symbol>
+  </part-group>
+  <score-part id="P1">
+   <part-name>Piano</part-name>
+   <part-name-display>
+    <display-text>Piano</display-text>
+   </part-name-display>
+   <part-abbreviation>Pno.</part-abbreviation>
+   <part-abbreviation-display>
+    <display-text>Pno.</display-text>
+   </part-abbreviation-display>
+   <score-instrument id="P1-I1">
+    <instrument-name>Piano (2) (2)</instrument-name>
+    <instrument-sound>keyboard.piano.grand</instrument-sound>
+    <solo />
+    <virtual-instrument>
+     <virtual-library>Sibelius 7 Sounds</virtual-library>
+     <virtual-name>Concert Grand Piano</virtual-name>
+    </virtual-instrument>
+   </score-instrument>
+  </score-part>
+  <part-group type="stop" number="1" />
+ </part-list>
+ <part id="P1">
+  <!--============== Part: P1, Measure: 1 ==============-->
+  <measure number="1" width="986">
+   <attributes>
+    <divisions>256</divisions>
+    <key color="#000000">
+     <fifths>0</fifths>
+     <mode>minor</mode>
+    </key>
+    <time color="#000000">
+     <beats>4</beats>
+     <beat-type>4</beat-type>
+    </time>
+    <staves>1</staves>
+    <clef number="1" color="#000000">
+     <sign>G</sign>
+     <line>2</line>
+    </clef>
+    <staff-details number="1" print-object="yes" />
+   </attributes>
+   <note color="#000000" default-x="96" default-y="9">
+    <pitch>
+     <step>A</step>
+     <octave>4</octave>
+    </pitch>
+    <duration>128</duration>
+    <instrument id="P1-I1" />
+    <voice>1</voice>
+    <type>eighth</type>
+    <stem>up</stem>
+    <staff>1</staff>
+    <beam number="1">begin</beam>
+   </note>
+   <note color="#000000" default-x="249" default-y="9">
+    <pitch>
+     <step>B</step>
+     <octave>4</octave>
+    </pitch>
+    <duration>128</duration>
+    <instrument id="P1-I1" />
+    <voice>1</voice>
+    <type>eighth</type>
+    <stem>up</stem>
+    <staff>1</staff>
+    <beam number="1">end</beam>
+   </note>
+   <note default-x="402">
+    <rest />
+    <duration>256</duration>
+    <instrument id="P1-I1" />
+    <voice>1</voice>
+    <type>quarter</type>
+    <staff>1</staff>
+   </note>
+   <note default-x="613">
+    <rest />
+    <duration>512</duration>
+    <instrument id="P1-I1" />
+    <voice>1</voice>
+    <type>half</type>
+    <staff>1</staff>
+   </note>
+   <barline>
+    <bar-style>light-heavy</bar-style>
+   </barline>
+   <backup>
+    <duration>1024</duration>
+   </backup>
+   <note color="#000000" default-x="96">
+    <pitch>
+     <step>G</step>
+     <octave>4</octave>
+    </pitch>
+    <duration>1024</duration>
+    <tie type="start" />
+    <instrument id="P1-I1" />
+    <voice>2</voice>
+    <type>whole</type>
+    <staff>1</staff>
+    <notations>
+     <tied type="start" orientation="under" />
+    </notations>
+   </note>
+  </measure>
+ </part>
+</score-partwise>

+ 140 - 0
test/data/test_note_overlap_staggering_whole_half.musicxml

@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <work>
+    <work-title>test note overlap whole+half</work-title>
+    </work>
+  <identification>
+    <encoding>
+      <software>MuseScore 3.6.2</software>
+      <encoding-date>2022-01-07</encoding-date>
+      <supports element="accidental" type="yes"/>
+      <supports element="beam" type="yes"/>
+      <supports element="print" attribute="new-page" type="no"/>
+      <supports element="print" attribute="new-system" type="no"/>
+      <supports element="stem" type="yes"/>
+      </encoding>
+    </identification>
+  <defaults>
+    <scaling>
+      <millimeters>7</millimeters>
+      <tenths>40</tenths>
+      </scaling>
+    <page-layout>
+      <page-height>1697.14</page-height>
+      <page-width>1200</page-width>
+      <page-margins type="even">
+        <left-margin>85.7143</left-margin>
+        <right-margin>85.7143</right-margin>
+        <top-margin>85.7143</top-margin>
+        <bottom-margin>85.7143</bottom-margin>
+        </page-margins>
+      <page-margins type="odd">
+        <left-margin>85.7143</left-margin>
+        <right-margin>85.7143</right-margin>
+        <top-margin>85.7143</top-margin>
+        <bottom-margin>85.7143</bottom-margin>
+        </page-margins>
+      </page-layout>
+    <word-font font-family="Edwin" font-size="10"/>
+    <lyric-font font-family="Edwin" font-size="10"/>
+    </defaults>
+  <credit page="1">
+    <credit-type>title</credit-type>
+    <credit-words default-x="600" default-y="1611.43" justify="center" valign="top" font-size="22">test note overlap</credit-words>
+    </credit>
+  <part-list>
+    <score-part id="P1">
+      <part-name>Piano</part-name>
+      <part-abbreviation>Pno.</part-abbreviation>
+      <score-instrument id="P1-I1">
+        <instrument-name>Piano</instrument-name>
+        </score-instrument>
+      <midi-device id="P1-I1" port="1"></midi-device>
+      <midi-instrument id="P1-I1">
+        <midi-channel>1</midi-channel>
+        <midi-program>1</midi-program>
+        <volume>78.7402</volume>
+        <pan>0</pan>
+        </midi-instrument>
+      </score-part>
+    </part-list>
+  <part id="P1">
+    <measure number="1" width="371.43">
+      <print>
+        <system-layout>
+          <system-margins>
+            <left-margin>65.90</left-margin>
+            <right-margin>591.24</right-margin>
+            </system-margins>
+          <top-system-distance>170.00</top-system-distance>
+          </system-layout>
+        <staff-layout number="2">
+          <staff-distance>65.00</staff-distance>
+          </staff-layout>
+        </print>
+      <attributes>
+        <divisions>1</divisions>
+        <key>
+          <fifths>0</fifths>
+          </key>
+        <time>
+          <beats>4</beats>
+          <beat-type>4</beat-type>
+          </time>
+        <staves>2</staves>
+        <clef number="1">
+          <sign>G</sign>
+          <line>2</line>
+          </clef>
+        <clef number="2">
+          <sign>F</sign>
+          <line>4</line>
+          </clef>
+        </attributes>
+      <note default-x="83.49" default-y="-20.00">
+        <pitch>
+          <step>B</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>4</duration>
+        <voice>1</voice>
+        <type>whole</type>
+        <staff>1</staff>
+        </note>
+      <backup>
+        <duration>4</duration>
+        </backup>
+      <note default-x="101.41" default-y="-20.00">
+        <pitch>
+          <step>B</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>2</voice>
+        <type>half</type>
+        <stem>down</stem>
+        <staff>1</staff>
+        </note>
+      <note>
+        <rest/>
+        <duration>2</duration>
+        <voice>2</voice>
+        <type>half</type>
+        <staff>1</staff>
+        </note>
+      <backup>
+        <duration>4</duration>
+        </backup>
+      <note>
+        <rest measure="yes"/>
+        <duration>4</duration>
+        <voice>5</voice>
+        <staff>2</staff>
+        </note>
+      <barline location="right">
+        <bar-style>light-heavy</bar-style>
+        </barline>
+      </measure>
+    </part>
+  </score-partwise>

+ 150 - 0
test/data/test_notehead_sharing_eighth_quarter.musicxml

@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <work>
+    <work-title>test notehead sharing eigth+quarter</work-title>
+    </work>
+  <identification>
+    <creator type="composer">Composer</creator>
+    <encoding>
+      <software>MuseScore 3.6.2</software>
+      <encoding-date>2022-01-08</encoding-date>
+      <supports element="accidental" type="yes"/>
+      <supports element="beam" type="yes"/>
+      <supports element="print" attribute="new-page" type="yes" value="yes"/>
+      <supports element="print" attribute="new-system" type="yes" value="yes"/>
+      <supports element="stem" type="yes"/>
+      </encoding>
+    </identification>
+  <defaults>
+    <scaling>
+      <millimeters>6.99911</millimeters>
+      <tenths>40</tenths>
+      </scaling>
+    <page-layout>
+      <page-height>1696.94</page-height>
+      <page-width>1200.48</page-width>
+      <page-margins type="even">
+        <left-margin>85.7252</left-margin>
+        <right-margin>85.7252</right-margin>
+        <top-margin>85.7252</top-margin>
+        <bottom-margin>85.7252</bottom-margin>
+        </page-margins>
+      <page-margins type="odd">
+        <left-margin>85.7252</left-margin>
+        <right-margin>85.7252</right-margin>
+        <top-margin>85.7252</top-margin>
+        <bottom-margin>85.7252</bottom-margin>
+        </page-margins>
+      </page-layout>
+    <word-font font-family="Edwin" font-size="10"/>
+    <lyric-font font-family="Edwin" font-size="10"/>
+    </defaults>
+  <credit page="1">
+    <credit-type>title</credit-type>
+    <credit-words default-x="600.242" default-y="1611.21" justify="center" valign="top" font-size="22">Title</credit-words>
+    </credit>
+  <credit page="1">
+    <credit-type>composer</credit-type>
+    <credit-words default-x="1114.76" default-y="1511.21" justify="right" valign="bottom">Composer</credit-words>
+    </credit>
+  <part-list>
+    <score-part id="P1">
+      <part-name>Piano</part-name>
+      <part-abbreviation>Pno.</part-abbreviation>
+      <score-instrument id="P1-I1">
+        <instrument-name>Piano</instrument-name>
+        </score-instrument>
+      <midi-device id="P1-I1" port="1"></midi-device>
+      <midi-instrument id="P1-I1">
+        <midi-channel>1</midi-channel>
+        <midi-program>1</midi-program>
+        <volume>78.7402</volume>
+        <pan>0</pan>
+        </midi-instrument>
+      </score-part>
+    </part-list>
+  <part id="P1">
+    <measure number="1" width="407.91">
+      <print>
+        <system-layout>
+          <system-margins>
+            <left-margin>50.00</left-margin>
+            <right-margin>570.79</right-margin>
+            </system-margins>
+          <top-system-distance>170.00</top-system-distance>
+          </system-layout>
+        </print>
+      <attributes>
+        <divisions>2</divisions>
+        <key>
+          <fifths>0</fifths>
+          </key>
+        <time>
+          <beats>4</beats>
+          <beat-type>4</beat-type>
+          </time>
+        <clef>
+          <sign>G</sign>
+          <line>2</line>
+          </clef>
+        </attributes>
+      <note default-x="80.72" default-y="-40.00">
+        <pitch>
+          <step>E</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        </note>
+      <note>
+        <rest/>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        </note>
+      <note>
+        <rest/>
+        <duration>4</duration>
+        <voice>1</voice>
+        <type>half</type>
+        </note>
+      <backup>
+        <duration>8</duration>
+        </backup>
+      <note default-x="80.72" default-y="-40.00">
+        <pitch>
+          <step>E</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        </note>
+      <note>
+        <rest/>
+        <duration>1</duration>
+        <voice>2</voice>
+        <type>eighth</type>
+        </note>
+      <note>
+        <rest/>
+        <duration>2</duration>
+        <voice>2</voice>
+        <type>quarter</type>
+        </note>
+      <note>
+        <rest/>
+        <duration>4</duration>
+        <voice>2</voice>
+        <type>half</type>
+        </note>
+      <barline location="right">
+        <bar-style>light-heavy</bar-style>
+        </barline>
+      </measure>
+    </part>
+  </score-partwise>

+ 246 - 0
test/data/test_ties_missing_1097.musicxml

@@ -0,0 +1,246 @@
+<?xml version="1.0" encoding='UTF-8' standalone='no' ?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.0">
+ <work>
+  <work-title>test_ties_missing_1097</work-title>
+ </work>
+ <identification>
+  <encoding>
+   <encoding-date>2021-11-17</encoding-date>
+   <software>Sibelius 19.5.0</software>
+   <software>Direct export, not from Dolet</software>
+   <encoding-description>Sibelius / MusicXML 3.0</encoding-description>
+   <supports element="print" type="yes" value="yes" attribute="new-system" />
+   <supports element="print" type="yes" value="yes" attribute="new-page" />
+   <supports element="accidental" type="yes" />
+   <supports element="beam" type="yes" />
+   <supports element="stem" type="yes" />
+  </encoding>
+ </identification>
+ <defaults>
+  <scaling>
+   <millimeters>215.9</millimeters>
+   <tenths>1233</tenths>
+  </scaling>
+  <page-layout>
+   <page-height>1596</page-height>
+   <page-width>1233</page-width>
+   <page-margins type="both">
+    <left-margin>85</left-margin>
+    <right-margin>85</right-margin>
+    <top-margin>85</top-margin>
+    <bottom-margin>85</bottom-margin>
+   </page-margins>
+  </page-layout>
+  <system-layout>
+   <system-margins>
+    <left-margin>63</left-margin>
+    <right-margin>0</right-margin>
+   </system-margins>
+   <system-distance>92</system-distance>
+  </system-layout>
+  <appearance>
+   <line-width type="stem">0.9375</line-width>
+   <line-width type="beam">5</line-width>
+   <line-width type="staff">0.9375</line-width>
+   <line-width type="light barline">1.5625</line-width>
+   <line-width type="heavy barline">5</line-width>
+   <line-width type="leger">1.5625</line-width>
+   <line-width type="ending">1.5625</line-width>
+   <line-width type="wedge">1.25</line-width>
+   <line-width type="enclosure">0.9375</line-width>
+   <line-width type="tuplet bracket">1.25</line-width>
+   <line-width type="bracket">5</line-width>
+   <line-width type="dashes">1.5625</line-width>
+   <line-width type="extend">0.9375</line-width>
+   <line-width type="octave shift">1.5625</line-width>
+   <line-width type="pedal">1.5625</line-width>
+   <line-width type="slur middle">1.5625</line-width>
+   <line-width type="slur tip">0.625</line-width>
+   <line-width type="tie middle">1.5625</line-width>
+   <line-width type="tie tip">0.625</line-width>
+   <note-size type="cue">75</note-size>
+   <note-size type="grace">60</note-size>
+  </appearance>
+  <music-font font-family="Opus Std" font-size="19.8425" />
+  <lyric-font font-family="Times New Roman" font-size="11.4715" />
+  <lyric-language xml:lang="en" />
+ </defaults>
+ <part-list>
+  <part-group type="start" number="1">
+   <group-symbol>brace</group-symbol>
+  </part-group>
+  <score-part id="P1">
+   <part-name>Piano</part-name>
+   <part-name-display>
+    <display-text>Piano</display-text>
+   </part-name-display>
+   <part-abbreviation>Pno.</part-abbreviation>
+   <part-abbreviation-display>
+    <display-text>Pno.</display-text>
+   </part-abbreviation-display>
+   <score-instrument id="P1-I1">
+    <instrument-name>Piano (2) (2)</instrument-name>
+    <instrument-sound>keyboard.piano.grand</instrument-sound>
+    <solo />
+    <virtual-instrument>
+     <virtual-library>General MIDI</virtual-library>
+     <virtual-name>Acoustic Piano</virtual-name>
+    </virtual-instrument>
+   </score-instrument>
+  </score-part>
+  <part-group type="stop" number="1" />
+ </part-list>
+ <part id="P1">
+  <!--============== Part: P1, Measure: 1 ==============-->
+  <measure number="1" width="986">
+   <attributes>
+    <divisions>256</divisions>
+    <key color="#000000">
+     <fifths>-3</fifths>
+     <mode>minor</mode>
+    </key>
+    <time color="#000000">
+     <beats>4</beats>
+     <beat-type>4</beat-type>
+    </time>
+    <staves>1</staves>
+    <clef number="1" color="#000000">
+     <sign>F</sign>
+     <line>4</line>
+    </clef>
+    <staff-details number="1" print-object="yes" />
+   </attributes>
+   <note color="#000000" default-x="115" default-y="17">
+    <pitch>
+     <step>A</step>
+     <alter>-1</alter>
+     <octave>2</octave>
+    </pitch>
+    <duration>384</duration>
+    <instrument id="P1-I1" />
+    <voice>1</voice>
+    <type>quarter</type>
+    <dot />
+    <stem>up</stem>
+    <staff>1</staff>
+   </note>
+   <note color="#000000" default-x="115">
+    <chord />
+    <pitch>
+     <step>E</step>
+     <alter>-1</alter>
+     <octave>3</octave>
+    </pitch>
+    <duration>256</duration>
+    <tie type="start" />
+    <instrument id="P1-I1" />
+    <type>quarter</type>
+    <dot />
+    <staff>1</staff>
+    <notations>
+     <tied type="start" orientation="over" />
+    </notations>
+   </note>
+   <note color="#000000" default-x="421" default-y="20">
+    <pitch>
+     <step>G</step>
+     <octave>2</octave>
+    </pitch>
+    <duration>128</duration>
+    <tie type="start" />
+    <instrument id="P1-I1" />
+    <voice>1</voice>
+    <type>eighth</type>
+    <stem>up</stem>
+    <staff>1</staff>
+    <notations>
+     <tied type="start" orientation="under" />
+    </notations>
+   </note>
+   <note color="#000000" default-x="421">
+    <chord />
+    <pitch>
+     <step>E</step>
+     <alter>-1</alter>
+     <octave>3</octave>
+    </pitch>
+    <duration>128</duration>
+    <tie type="stop" />
+    <tie type="start" />
+    <instrument id="P1-I1" />
+    <type>eighth</type>
+    <staff>1</staff>
+    <notations>
+     <tied type="stop" orientation="over" />
+     <tied type="start" orientation="under" />
+    </notations>
+   </note>
+   <note color="#000000" default-x="421">
+    <chord />
+    <pitch>
+     <step>F</step>
+     <octave>3</octave>
+    </pitch>
+    <duration>128</duration>
+    <tie type="start" />
+    <instrument id="P1-I1" />
+    <type>eighth</type>
+    <staff>1</staff>
+    <notations>
+     <tied type="start" orientation="over" />
+    </notations>
+   </note>
+   <note color="#000000" default-x="585" default-y="20">
+    <pitch>
+     <step>G</step>
+     <octave>2</octave>
+    </pitch>
+    <duration>512</duration>
+    <tie type="stop" />
+    <instrument id="P1-I1" />
+    <voice>1</voice>
+    <type>half</type>
+    <stem>up</stem>
+    <staff>1</staff>
+    <notations>
+     <tied type="stop" orientation="under" />
+    </notations>
+   </note>
+   <note color="#000000" default-x="585">
+    <chord />
+    <pitch>
+     <step>E</step>
+     <alter>-1</alter>
+     <octave>3</octave>
+    </pitch>
+    <duration>512</duration>
+    <tie type="stop" />
+    <instrument id="P1-I1" />
+    <type>half</type>
+    <staff>1</staff>
+    <notations>
+     <tied type="stop" orientation="under" />
+    </notations>
+   </note>
+   <note color="#000000" default-x="585">
+    <chord />
+    <pitch>
+     <step>F</step>
+     <octave>3</octave>
+    </pitch>
+    <duration>512</duration>
+    <tie type="stop" />
+    <instrument id="P1-I1" />
+    <type>half</type>
+    <staff>1</staff>
+    <notations>
+     <tied type="stop" orientation="over" />
+    </notations>
+   </note>
+   <barline>
+    <bar-style>light-heavy</bar-style>
+   </barline>
+  </measure>
+ </part>
+</score-partwise>

+ 7 - 3
webpack.common.js

@@ -11,11 +11,15 @@ module.exports = {
         path: path.resolve(__dirname, 'build'),
         filename: '[name].js',
         library: 'opensheetmusicdisplay',
-        libraryTarget: 'umd'
+        libraryTarget: 'umd',
+        globalObject: 'this'
     },
     resolve: {
+        alias: {
+            handlebars: 'handlebars/dist/handlebars.min.js'
+        },
         // Add '.ts' and '.tsx' as a resolvable extension.
-        extensions: ['.ts', '.tsx', '.js']
+        extensions: ['.ts', '.tsx', '.js'],
     },
     module: {
         rules: [
@@ -58,7 +62,7 @@ module.exports = {
         })
     ],
     devServer: {
-        contentBase: [
+        static: [
             path.join(__dirname, 'test/data'),
             path.join(__dirname, 'build'),
             path.join(__dirname, 'demo')

+ 1 - 1
webpack.dev.js

@@ -1,4 +1,4 @@
-var merge = require('webpack-merge')
+var { merge } = require('webpack-merge')
 var common = require('./webpack.common.js')
 var webpack = require('webpack')
 

+ 2 - 2
webpack.prod.js

@@ -1,8 +1,8 @@
-var merge = require('webpack-merge')
+const { merge } = require('webpack-merge');
 var webpack = require('webpack')
 var path = require('path')
 var common = require('./webpack.common.js')
-var Visualizer = require('webpack-visualizer-plugin')
+var Visualizer = require('webpack-visualizer-plugin2')
 const { CleanWebpackPlugin } = require('clean-webpack-plugin')
 
 module.exports = merge(common, {

+ 1 - 1
webpack.sourcemap.js

@@ -1,4 +1,4 @@
-var merge = require('webpack-merge')
+var { merge } = require('webpack-merge')
 var production = require('./webpack.prod.js')
 
 // will create a build plus separate .min.js.map source map for debugging