diff --git a/.agents/skills/render-musicxml/SKILL.md b/.agents/skills/render-musicxml/SKILL.md new file mode 100644 index 000000000..834a3c2f1 --- /dev/null +++ b/.agents/skills/render-musicxml/SKILL.md @@ -0,0 +1,34 @@ +--- +name: render-musicxml +description: Render a MusicXML file in vexml by running `vex render -i `, inspect the generated screenshot when needed, and delete ephemeral render output when finished. +--- + +# Render MusicXML + +Use this skill when the user asks to render a MusicXML file, inspect how a MusicXML fixture looks, generate a quick screenshot from MusicXML, or verify rendering behavior visually without adding or updating an integration test. + +## Command + +Run the render command from the project root: + +```sh +vex render -i +``` + +Replace `` with the project-relative or absolute path to the MusicXML file the user wants rendered. + +## Workflow + +1. Identify the MusicXML input path from the user's request or from the repository. +2. Run `vex render -i ` from `vexml`. +3. Read the command output to find the generated screenshot path. +4. If visual inspection is needed, open or inspect the generated screenshot with available tools. +5. When finished, delete the generated screenshot if it was only meant to be ephemeral. + +## Ephemeral Output + +Treat the generated screenshot as ephemeral when it was created only for quick inspection, debugging, or answering a one-off question. + +Do **not** delete the screenshot when the user explicitly asks to keep it, save it, compare it later, attach it to a report, or use it as a baseline/test artifact. + +When deleting an ephemeral screenshot, remove only the screenshot generated by the `vex render` command you just ran. Do not remove existing fixtures, baselines, or unrelated image files. diff --git a/.agents/skills/test-musicxml/SKILL.md b/.agents/skills/test-musicxml/SKILL.md new file mode 100644 index 000000000..adebd0c4d --- /dev/null +++ b/.agents/skills/test-musicxml/SKILL.md @@ -0,0 +1,108 @@ +--- +name: test-musicxml +description: Add or update vexml MusicXML integration tests, validate screenshot output, implement rendering fixes, and selectively update baselines. +--- + +# Test MusicXML + +Use this skill when adding or updating a `vexml` MusicXML rendering test case, especially when the work involves integration fixtures, screenshots, or baseline updates. + +## Workflow + +**Important:** Use the `vex test` commands shown below, plus selective `vex test --update ` only after reviewing the screenshot output. + +1. First, check whether an existing test in `tests/integration/` already covers the use case. + - Inspect the relevant integration case definitions and existing files under `tests/integration/__data__/`. + - Reuse or extend an existing case when that is the least surprising option. + +2. If the use case is not already covered, add it to `tests/integration/__data__/` — preferably as a new measure inside the existing fixture for that category rather than as a brand-new file. + + **Bundle by category; treat each measure as a pseudo unit test.** A category fixture (e.g. `key.musicxml`, `time.musicxml`, `note.musicxml`, `slur.musicxml`) is one MusicXML document whose measures each isolate one variant of that category's behavior — the way a unit-test file holds many small cases. The measure is the unit of isolation, not the file. For example, `key.musicxml` proves a sharp key, a flat key, a mid-system key change, and a no-redraw continuation across M1-M3; `slur.musicxml` walks nine slur scenarios across nine measures. When adding a new variant of an already-covered category, append a measure to that category's fixture (and a `M:` bullet to its comment) instead of creating a near-duplicate file. When you find existing same-category fixtures that each test one variant (e.g. `key_sharps` + `key_flats` + `key_change`), consolidate them into one. + + - **When bundling does NOT apply:** some things are fixed for a whole document and can't vary per measure — the `` and each part's stave configuration (stave count, tab string count, braces). Those stay as separate `category_variant.musicxml` files. This is why `structure_*` (different part-lists) and `clef_*` (single stave vs grand staff vs 4-/6-line tab) are not bundled: each needs a different part/stave structure, not just a different measure. Rule of thumb: bundle what varies per measure (keys, meters, note/rest/accidental/articulation/beam/slur/tie/tuplet variants, voices); keep separate what needs a different part or stave layout. + - Name a fixture that covers a whole category `category.musicxml` (e.g. `key`, `time`, `note`, `slur`). Use `category_variant.musicxml` only when different part/stave structures force more than one file in that category (e.g. `clef_tab_4_string`, `structure_grand_staff`). Categories: `structure`, `clef`, `key`, `time`, `note`, `rest`, `accidental`, `measures`, …; match the existing files in `tests/integration/__data__/`. + - Register it in `tests/integration/render.test.ts` by adding `testCase('.musicxml', '.png')` to the `TEST_CASES` array. Pass any non-default `Config` as the third argument. + - Keep `TEST_CASES` ordered by increasing rendering complexity. A `render` implementer should be able to build a correct renderer progressively by going through the tests in array order: basic structure before clefs, clefs before key/time signatures, simple notes/rests before accidentals, measures, beams, chords, ties/slurs, tuplets, articulations, voices, system layout, and broad stress cases. Insert new cases where they fit this progression; the array order is the only ordering — there is no numeric prefix. A bundled category fixture sits in its category's slot; let its most complex measure set the position. + - Above each `testCase(...)` declaration, write a detailed comment describing what the screenshot should render: the clefs, staves, notes, and any distinctive notation or layout (positions, accidentals, beams, slurs, ledger lines, system breaks, …). Describe what is actually drawn so the comment alone tells a reader what to expect without opening the PNG. Match the descriptive style of the surrounding cases. + - For cases spanning more than one measure, split the comment by measure for readability. Start with a one-line lead describing the global setup (stave/clef/time signature and any wrap behavior), then add one bulleted line per measure using a stable `M:` marker (`M2-3:` for a span). Wrap continuation lines so they align under the bullet text. For multi-voice cases, use inline `V` markers (e.g. `V1`, `V2`) inside each measure bullet. This keeps the comment scannable for humans and greppable for tools, with each `M` mapping directly to a `` in the fixture. Example: + + ```ts + // Treble stave, 4/4: dotted-note variations. + // - M1: dotted-quarter + eighth pairs (single dots). + // - M2: double-dotted-quarter + sixteenth pairs (double dots). + testCase('dotted_notes.musicxml', 'dotted_notes.png'), + ``` + - Each measure should test only one thing. Vary one feature per measure and keep everything else stable (pitch, duration, clef); don't vary unrelated musical features within a measure unless the variation is part of what that measure is proving. The fixture as a whole deliberately spans many variants of its category — but each measure isolates exactly one. + - Wrong pattern: when testing articulations, do not vary duration or pitch needlessly; use stable, boring notes unless pitch or duration affects the articulation behavior being tested. + - Right pattern: when testing beams, varying pitch can be useful if the goal is to show how the beam renders under normal and extreme stem/ledger-line conditions. There should be a clear reason for every extra variation. + - Keep each measure as small as practical while still demonstrating its variant, and the fixture as a whole no larger than its variants require. + - Keep generated MusicXML fixtures as simple and barebones as possible; inspect nearby existing files and match their minimal structure and style. + +3. Run the integration test command: + + ```sh + vex test + ``` + +4. Interpret screenshot results carefully. Screenshot tests can fail or pass for two different reasons: + - **False positive:** a newly added test may pass only because its first generated screenshot was automatically accepted as the baseline. In this state the test accepts any current rendering as correct, even if the rendering is visibly wrong. Leave a `TODO` comment above the `testCase(...)` explicitly calling out this failure mode, for example: `// TODO: False positive: this baseline was probably created from the current render, so it may be accepting an incorrect screenshot. Review the render, then run vex test --update only after the image is confirmed correct.` If the user provides a golden-standard image for the case, compare it against vexml's render before accepting. Eventually, the agent must run `vex test --update` to intentionally accept the reviewed screenshot. + - **True negative:** an existing screenshot test failed because a previously accepted baseline is no longer reproducible. Inspect the diff artifact and leave a `TODO` comment above the `testCase(...)` that describes the visual difference in plain language and links to the diff. Do not prescribe a fix unless the root cause is already clear. Prefer wording like: `// TODO: True negative: the accepted baseline shows , but the new render shows . Diff: .` + - In both cases, describe what a human should look for in the screenshot. Make the TODO specific enough that another agent can continue from it without opening unrelated files. + - If the correct rendering is ambiguous, ask the user for feedback and include file links to the relevant screenshot diff or artifact. + +5. Update the implementation in `src/` to fill the rendering gap. + - Prefer a minimal, root-cause fix. + - Keep changes consistent with the existing renderer and test patterns. + +6. Run the test command again: + + ```sh + vex test + ``` + +7. The target test may still fail. That is useful if it shows the implementation changed the rendered output. + - For the target test file, inspect the new render and confirm it matches the intended rendering described by the relevant test comment or TODO. + - Before updating any screenshot baseline, always perform the screenshot review checklist below. + - If a `TODO` describes a false positive, keep it until the screenshot has been manually reviewed and intentionally accepted with `vex test --update`. + - If a `TODO` describes a true negative, keep it until the changed screenshot has been explained, fixed, or intentionally accepted. + - Do not update baselines until you have evaluated the screenshot output. + +## Screenshot Review Checklist + +Always run this checklist before accepting or updating a screenshot baseline. Answer these questions from the actual screenshot or diff artifact, not from assumptions about the code: + +- Is everything visible? Look for clefs, key signatures, time signatures, notes, rests, articulations, accidentals, ledger lines, beams, ties, slurs, lyrics, barlines, and other symbols that are cut off by the image bounds or page margins. +- Are the results sized appropriately for the render options and the fixture? The image should not look accidentally zoomed in, zoomed out, cropped, or padded with excessive empty space. +- Are musical glyphs clashing when they should not? Check collisions between noteheads, stems, beams, flags, accidentals, dots, rests, text, ties, slurs, clefs, key signatures, time signatures, and neighboring staves/measures. +- Given the render options, does the music feel too cramped? Look for notation that is technically visible but hard to read because horizontal or vertical spacing is too tight. +- Given the render options, does the music feel too spaced out? Look for measures, systems, or glyphs that are spread so far apart that the fixture no longer demonstrates the intended behavior clearly. +- What does the test comment describe? Does the screenshot match that description, including the named clefs, staves, notes, rests, accidentals, beams, slurs, ledger lines, system breaks, and layout expectations? +- Are all expected musical elements present exactly once, with no obvious duplicates, missing glyphs, wrong glyphs, or stale artifacts from a previous render? +- Are stems, beams, flags, dots, accidentals, and rests positioned in musically plausible places relative to the staff and notes? +- If this is a diff, can you explain the visual change in plain language from the diff artifact before updating the baseline? +- If any answer is uncertain, do not update the baseline yet. Add or keep a `TODO` that names the uncertainty and links to the screenshot or diff artifact. + +8. If the target render passes the screenshot review checklist, update only that baseline: + + ```sh + vex test --update + ``` + + Where `` is the test title — the screenshot filename passed as the second argument to `testCase()` in `tests/integration/render.test.ts` (helper in `tests/testing/test-case.ts`), e.g. `clef_treble.png`. The pattern matches by prefix, so `clef_treble` also matches. + +9. Validate that there are no regressions. + - Run `vex test` again after the selective baseline update. + - Per project rules, also run `vex fix` after code changes. + - If you deleted any integration test cases, run `vex test --clean` globally to remove orphaned screenshot baselines. Do not target a single test when cleaning deleted cases. + - If regressions appear, eagerly add or list `TODO` comments for each regression using the false-positive or true-negative language from step 4. Include the plain-language visual difference and the relevant diff path or artifact link. + - Do not update the whole suite by default. Create a plan for each regression and explain whether it is expected or unexpected. + +## Describing Screenshot Diffs + +Whenever you need to verbalize a screenshot difference — a regression diff artifact in `tests/integration/__diffs__`, or a comparison of vexml's current render against a golden-standard image the user provided (common for new test cases) — inspect the original image path(s) directly and describe the difference in plain language. + +Do **not** create transformed derivatives for diff work unless there is no other practical way to understand the artifact. Prefer the original screenshot or diff artifact. For a `__diffs__` artifact, remember that the image has old / diff / new vertical sections. For a golden-standard comparison, keep clear which image is the vexml render and which is the golden image. + +## Baseline Update Guidance + +Avoid running `vex test --update` for the whole suite unless the change is intentionally global and every affected render has passed the screenshot review checklist. Prefer one-by-one baseline updates after confirming why each render changed. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index 4f7917a43..000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' ---- - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Actual behavior** -A clear and concise description of what actually happened. - -**Screenshot** -A screenshot of the `vexml` rendering. - -**Hints** -The more you provide, the faster we can fix it. - -- [ ] Attach the MusicXML file used to produce the bug. -- [ ] Attach the error messages. -- [ ] Include any `vexml` code references where the bug may be. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1a4c420db..fa5283db5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,53 +1,37 @@ -# Simple workflow for deploying static content to GitHub Pages -# See https://vitejs.dev/guide/static-deploy#github-pages +# Builds the site/ playground and deploys it to GitHub Pages (vexml.dev). +# Pages source must be set to "GitHub Actions" in repo settings. name: deploy on: - # Runs on pushes targeting the default branch push: branches: - - 'master' - - # Allows you to run this workflow manually from the Actions tab + - "master" workflow_dispatch: -# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write -# Allow one concurrent deployment +# One deploy at a time; newer pushes cancel in-flight runs. concurrency: - group: 'pages' + group: "pages" cancel-in-progress: true jobs: - # Single deploy job since we're just deploying deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Install dependencies - # Includes @rollup/rollup-linux-x64-gnu, which is an optional dependency required for Vite. - run: npm install @rollup/rollup-linux-x64-gnu --save-dev - - name: Build - run: npx vex build site - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bunx vite build site + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 with: - path: './site/dist' - - name: Deploy to GitHub Pages - id: deployment + path: "./site/dist" + - id: deployment uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 544db03bd..d9f577ea6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,8 @@ +# Premerge checks: format/lint/typecheck + unit and visual-regression tests. +# `vex test` builds the Dockerfile and runs the suite inside it, so screenshot +# baselines match local runs. Docker is preinstalled on ubuntu-latest. name: test + on: pull_request: push: @@ -10,16 +14,21 @@ jobs: name: run premerge code checks runs-on: ubuntu-latest steps: - - name: checkout - uses: actions/checkout@v4 - - name: setup node - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: echo "$PWD/bin" >> "$GITHUB_PATH" + - run: vex fix --check + # Build the test image once with gha layer caching and load it into the + # daemon. vex test then reuses it (VEX_TEST_SKIP_BUILD) instead of rebuilding. + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 with: - node-version: 22.6.0 - cache: npm - - name: install - run: npm ci - - name: fix - run: npx vex fix --check - - name: test - run: npx vex test --ci + context: . + load: true + tags: vexml-tests + cache-from: type=gha + cache-to: type=gha,mode=max + - run: vex test + env: + VEX_TEST_SKIP_BUILD: '1' diff --git a/.gitignore b/.gitignore index a435a0f7f..0303cb2d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ -dist node_modules -.DS_STORE -__diff_output__ -coverage -__tmp_image_snapshots__ -.claude +site/dist +.zed +tests/integration/__diffs__/ +.DS_Store +.playwright-mcp diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 521a9f7c0..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -legacy-peer-deps=true diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index f831562dd..000000000 --- a/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -*.xml -*.musicxml -dist diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 48dd729d3..000000000 --- a/.prettierrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "es5", - "bracketSpacing": true, - "arrowParens": "always", - "semi": true, - "printWidth": 120 -} diff --git a/AGENTS.md b/AGENTS.md index f9faa9675..d8f8d2ead 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,4 @@ -Use the `vex` or command for development. If it's not available, use `npx vex` instead. +After making code changes: -After making changes, format, lint, typecheck, and run tests in the packages that were affected. -`vex fix` -`vex test --local` +- Run `vex fix` to typecheck, format, and lint the project. +- Run `vex test` to test the project. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 285e0f5b3..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@./AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 3e3d7f92c..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,49 +0,0 @@ -# Contributing to vexml - -## Workflow - -For development environment setup, please refer to the [Development section](README.md#development) in the README.md. - -1. Fork the repository -2. Make your changes, following the codebase's style -3. Commit using conventional commit style: `"fix issue with X"` -4. Push to your fork and open a Pull Request - -We want minimal friction in the contribution process, so standalone improvements are absolutely welcome—no need to link to an issue first. - -## Code Quality - -Before submitting your contribution, ensure the following checks pass: - -```sh -# Format, lint, and typecheck (writes fixes) -npx vex fix - -# Or check without fixing (what CI runs) -npx vex fix --check - -# Run tests (requires Docker) -npx vex test -``` - -All of these checks are run automatically in CI via GitHub Actions and must pass before your PR can be merged. - -## Testing - -If you add a new feature, it's **preferred (but not required)** that you include a test that exercises that feature. - -Integration tests live in [`tests/integration/`](tests/integration/) and are comprehensive of the MusicXML spec. Browse the existing test files to see if your use case is covered. If it's not, please add to the catch-all [`tests/integration/vexml.test.ts`](tests/integration/vexml.test.ts) test suite. - -## Pull Request Requirements - -Keep it simple: - -- **Brief description** of what you changed and why -- **Screenshot** (or short video) of the achieved outcome - -That's it! The review process and CI checks will handle the rest. - -## Getting Help - -- **GitHub Issues**: Best place for specific problems or questions -- **Pull Request Discussions**: Feel free to open a PR early and start a discussion there diff --git a/Dockerfile b/Dockerfile index 0855169a4..4c0bd9cbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,16 @@ -FROM ghcr.io/puppeteer/puppeteer:23.9.0 +# Pinned to the installed playwright version so the bundled Chromium matches. +# This image is the source of pixel determinism: same OS, fonts, and browser build as CI. +FROM mcr.microsoft.com/playwright:v1.61.1-jammy -ENV VEXML_CANONICAL_TEST_ENV=true +# Bun, copied from its official image (no curl install needed). +COPY --from=oven/bun:1 /usr/local/bin/bun /usr/local/bin/bun -# Workaround for puppeteer base image setting USER: -# https://github.com/puppeteer/puppeteer/blob/c764f82c7435bdc10e6a9007892ab8dba111d21c/docker/Dockerfile# -# Also see: https://github.com/nodejs/docker-node/issues/740 -USER root +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile -WORKDIR /vexml +COPY . . -# Install dependencies. -COPY package.json . -COPY package-lock.json . -COPY .npmrc . -RUN npm ci - -# Copy config. -COPY jest.config.js . -COPY babel.config.json . -COPY PuppeteerEnvironment.js . -COPY globalSetup.js . -COPY globalTeardown.js . -COPY jest.setup.js . - -# Copy the code needed to run the dev server and tests. -COPY src src -COPY tests tests - -# Run the test by default. -CMD ["npx", "jest"] +# No dir: bun discovers all *.test.ts (unit + integration), skipping node_modules. +# ENTRYPOINT (not CMD) so `docker run vexml-tests ` appends to bun test. +ENTRYPOINT ["bun", "test"] diff --git a/PuppeteerEnvironment.js b/PuppeteerEnvironment.js deleted file mode 100644 index 985068801..000000000 --- a/PuppeteerEnvironment.js +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -// Adapted from https://jestjs.io/docs/puppeteer. - -import { TestEnvironment } from 'jest-environment-jsdom'; -import fs from 'fs'; -import path from 'path'; -import puppeteer from 'puppeteer'; -import os from 'os'; - -const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup'); - -export default class PuppeteerEnvironment extends TestEnvironment { - constructor(...args) { - super(...args); - - this.global.structuredClone = globalThis.structuredClone; - } - - async setup() { - await super.setup(); - // get the wsEndpoint - const wsEndpoint = fs.readFileSync(path.join(DIR, 'wsEndpoint'), 'utf8'); - if (!wsEndpoint) { - throw new Error('wsEndpoint not found'); - } - - // connect to puppeteer - this.global.__BROWSER_GLOBAL__ = await puppeteer.connect({ - browserWSEndpoint: wsEndpoint, - }); - } -} diff --git a/README.md b/README.md index 651ef00ae..85c158661 100644 --- a/README.md +++ b/README.md @@ -1,342 +1,71 @@ # vexml -![test workflow](https://github.com/stringsync/vexml/actions/workflows/test.yml/badge.svg) +## Getting Started -[Demo](https://vexml.dev) - -`vexml` is an open source library that renders [MusicXML](https://www.w3.org/2021/06/musicxml40/) using [vexflow](https://github.com/vexflow/vexflow). - -## Usage - -### Installation +Install the package. ```sh npm install @stringsync/vexml ``` -> [!NOTE] -> I recommend you to lock into a specific version to avoid breakages due to `vexml` API changes. - -### ⚠️ IMPORTANT: Loading Fonts - -`vexml` uses `vexflow` 5^, which requires you to load the fonts you need to use. See the [vexflow](https://github.com/vexflow/vexflow) repo for more information. - -### Rendering - -Rendering requires you to provide a valid MusicXML string and an `HTMLDivElement`. +Import the `render` function. ```ts -import * as vexml from '@stringsync/vexml'; - -const musicXML = 'some valid musicXML string'; -const div = document.getElementById('my-id'); -const score = vexml.renderMusicXML(musicXML, div); +import { render } from '@stringsync/vexml'; ``` -You can also render MXL given a `Blob` input. - -```ts -import * as vexml from '@stringsync/vexml'; - -const mxl = new Blob(['some', 'valid', 'mxl', 'bytes']); -const div = document.getElementById('my-id'); -const scorePromise = vexml.renderMXL(musicXML, div); -// From here, you need to await or call then() on the scorePromise to extract the score. -``` - -## Advanced Usage - -### Config - -To see an exhaustive list of configuration options, see [config.ts](./src/config.ts). You can experiment with all configs on dev site or [vexml.dev](https://vexml.dev). - -### Events - -The event listening API is similar to `EventTarget.addEventListener`, except you need to save a reference to the returned handle to unsubscribe. +Render MusicXML. ```ts -const score = vexml.renderMusicXML(musicXML, div); - -const handle = score.addEventListener('click', (e) => { - console.log(e); -}); - -// ... - -score.removeEventListener(handle); -``` - -Events work the same for both canvas and svg backends. - -### Cursors - -Cursors mark a position in a rendered vexml score. You can step through a piece entry-by-entry or seek a specific timestamp. - -- **Add** a cursor _model_ for the part you're interested in. -- **Render** a cursor _component_ to the score's overlay. -- **Listen** update the component to react to model changes. - -> [!TIP] -> Rendering a cursor _component_ is optional. - -```ts -import * as vexml from '@stringsync/vexml'; - -// ... - -const score = vexml.renderMusicXML(musicXML, div); - -// Add -const cursorModel = score.addCursor(); - -// Render -const cursorComponent = vexml.SimpleCursor.render(score.getOverlayElement()); - -// Listen -cursorModel.addEventListener( - 'change', - (e) => { - cursorComponent.update(e.rect); - // The model infers its visibility via the rect. It assumes you've updated appropriately. - if (!cursorModel.isFullyVisible()) { - cursorModel.scrollIntoView(scrollBehavior); - } - }, - { emitBootstrapEvent: true } -); +const res = await fetch('song.musicxml'); +const musicXML = await res.text(); +await render(musicXML, element); ``` -See [Vexml.tsx](./site/src/components/Vexml.tsx) for an example in React. - -### Custom Rendering - -`renderMusicXML` is the standard way to orchestrate `vexml` objects. If you need more grannular control, you need to do the following: - -- **Declare** a `vexml` configuration. -- **Parse** the MusicXML into a vexml data document. -- **Format** the vexml data document. -- **Render** the formatted vexml data document. +Render MXL (compressed MusicXML). ```ts -import * as vexml from '@stringsync/vexml'; - -// Declare -const config = { ...vexml.DEFAULT_CONFIG, WIDTH: 600 }; -const logger = new vexml.ConsoleLogger(); - -// Parse -const parser = new vexml.MusicXMLParser({ config }); -const document = parser.parse(musicXML); - -// Format -const defaultFormatter = new vexml.DefaultFormatter({ config }); -const monitoredFormatter = new vexml.MonitoredFormatter(defaultFormatter, logger, { config }); -const formattedDocument = monitoredFormatter.format(document); - -// Render -const renderer = new vexml.Renderer({ config, formatter: monitoredFormatter, logger }); -const score = renderer.render(div, formattedDocument); +const res = await fetch('song.mxl'); +const mxl = await res.blob(); +await render(mxl, element); ``` -> [!IMPORTANT] -> I highly recommend you pass the same config object to all vexml objects. Otherwise, you may get unexpected results. +Use custom fonts (optional). -See [render.ts](./src/render.ts) and [Vexml.tsx](./site/src/components/Vexml.tsx) for more examples. - -### Gap Measures - -Gap measures are non-musical fragments that optionally have a label. This is useful when syncing a `vexml` cursor with media that has non-musical pauses in it (e.g. a video of a teacher explaining a musical concept). - -![gap measure example](https://github.com/user-attachments/assets/11023cbb-3d20-4405-a5c6-95f36585dd93) - -You should create these right after you parse a document, specifically before format you it. Otherwise, the gap may invalidate the format's output. +> [!IMPORTANT] +> Font `family` and `url` are interpolated into a ` - - -
-
-
- -`; - - const parser = new DOMParser(); - - const document = parser.parseFromString(html, 'text/html'); - const vexmlDiv = document.getElementById('vexml') as HTMLDivElement; - - return { document, vexmlDiv, screenshotElementSelector: '#screenshot' }; -}; - -const cssFontFaceRule = (font: Font) => { - return ` - @font-face { - font-family: '${font.family}'; - src: url(${font.cdn.url}) format('${font.cdn.format}'); - } - `; -}; - -const registerFonts = () => { - for (const font of FONTS) { - registerFont(font.local.url, { family: font.family }); - } -}; diff --git a/tests/integration/lilypond.test.ts b/tests/integration/lilypond.test.ts deleted file mode 100644 index 8b5764391..000000000 --- a/tests/integration/lilypond.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Page } from 'puppeteer'; -import * as path from 'path'; -import * as fs from 'fs'; -import { setup, getSnapshotIdentifier } from './helpers'; -import * as vexml from '@/index'; - -type TestCase = { - filename: string; - width: number; -}; - -const DATA_DIR = path.resolve(__dirname, '..', '__data__', 'lilypond'); - -describe('lilypond', () => { - let page: Page; - - beforeAll(async () => { - page = await (globalThis as any).__BROWSER_GLOBAL__.newPage(); - }); - - afterAll(async () => { - await page.close(); - }); - - // https://lilypond.org/doc/v2.23/input/regression/musicxml/collated-files.html - it.each([ - { filename: '01a-Pitches-Pitches.musicxml', width: 900 }, - { filename: '01b-Pitches-Intervals.musicxml', width: 900 }, - { filename: '01c-Pitches-NoVoiceElement.musicxml', width: 900 }, - { filename: '01e-Pitches-ParenthesizedAccidentals.musicxml', width: 900 }, - { filename: '01d-Pitches-Microtones.musicxml', width: 900 }, - { filename: '01f-Pitches-ParenthesizedMicrotoneAccidentals.musicxml', width: 900 }, - { filename: '02a-Rests-Durations.musicxml', width: 900 }, - { filename: '02b-Rests-PitchedRests.musicxml', width: 900 }, - { filename: '02c-Rests-MultiMeasureRests.musicxml', width: 900 }, - { filename: '02d-Rests-Multimeasure-TimeSignatures.musicxml', width: 900 }, - { filename: '02e-Rests-NoType.musicxml', width: 900 }, - { filename: '03a-Rhythm-Durations.musicxml', width: 900 }, - { filename: '03b-Rhythm-Backup.musicxml', width: 900 }, - { filename: '03c-Rhythm-DivisionChange.musicxml', width: 900 }, - { filename: '03d-Rhythm-DottedDurations-Factors.musicxml', width: 900 }, - { filename: '11a-TimeSignatures.musicxml', width: 900 }, - { filename: '11b-TimeSignatures-NoTime.musicxml', width: 900 }, - { filename: '11c-TimeSignatures-CompoundSimple.musicxml', width: 900 }, - { filename: '11d-TimeSignatures-CompoundMultiple.musicxml', width: 900 }, - { filename: '11e-TimeSignatures-CompoundMixed.musicxml', width: 900 }, - { filename: '11f-TimeSignatures-SymbolMeaning.musicxml', width: 900 }, - { filename: '11g-TimeSignatures-SingleNumber.musicxml', width: 900 }, - { filename: '11h-TimeSignatures-SenzaMisura.musicxml', width: 900 }, - { filename: '12a-Clefs.musicxml', width: 900 }, - { filename: '12b-Clefs-NoKeyOrClef.musicxml', width: 900 }, - { filename: '13a-KeySignatures.musicxml', width: 900 }, - { filename: '13b-KeySignatures-ChurchModes.musicxml', width: 900 }, - { filename: '13c-KeySignatures-NonTraditional.musicxml', width: 900 }, - { filename: '13d-KeySignatures-Microtones.musicxml', width: 900 }, - { filename: '14a-StaffDetails-LineChanges.musicxml', width: 900 }, - { filename: '21a-Chord-Basic.musicxml', width: 900 }, - { filename: '21b-Chords-TwoNotes.musicxml', width: 900 }, - { filename: '21c-Chords-ThreeNotesDuration.musicxml', width: 900 }, - { filename: '21d-Chords-SchubertStabatMater.musicxml', width: 900 }, - { filename: '21e-Chords-PickupMeasures.musicxml', width: 900 }, - { filename: '21f-Chord-ElementInBetween.musicxml', width: 900 }, - { filename: '22a-Noteheads.musicxml', width: 900 }, - // { filename: '22b-Staff-Notestyles.musicxml', width: 900 }, - { filename: '22c-Noteheads-Chords.musicxml', width: 900 }, - { filename: '22d-Parenthesized-Noteheads.musicxml', width: 900 }, - { filename: '23a-Tuplets.musicxml', width: 900 }, - // { filename: '23b-Tuplets-Styles.musicxml', width: 900 }, - // { filename: '23c-Tuplet-Display-NonStandard.musicxml', width: 900 }, - // { filename: '23d-Tuplets-Nested.musicxml', width: 900 }, - // { filename: '23e-Tuplets-Tremolo.musicxml', width: 900 }, - // { filename: '23f-Tuplets-DurationButNoBracket.musicxml', width: 900 }, - { filename: '24a-GraceNotes.musicxml', width: 900 }, - { filename: '24b-ChordAsGraceNote.musicxml', width: 900 }, - // { filename: '24c-GraceNote-MeasureEnd.musicxml', width: 900 }, - // { filename: '24d-AfterGrace.musicxml', width: 900 }, - // { filename: '24e-GraceNote-StaffChange.musicxml', width: 900 }, - { filename: '24f-GraceNote-Slur.musicxml', width: 900 }, - { filename: '31a-Directions.musicxml', width: 900 }, - { filename: '31c-MetronomeMarks.musicxml', width: 900 }, - { filename: '32a-Notations.musicxml', width: 900 }, - // { filename: '32b-Articulations-Texts.musicxml', width: 900 }, - // { filename: '32c-MultipleNotationChildren.musicxml', width: 900 }, - // { filename: '32d-Arpeggio.musicxml', width: 900 }, - { filename: '33a-Spanners.musicxml', width: 900 }, - { filename: '33b-Spanners-Tie.musicxml', width: 900 }, - { filename: '33c-Spanners-Slurs.musicxml', width: 900 }, - // { filename: '33d-Spanners-OctaveShifts.musicxml', width: 900 }, - // { filename: '33e-Spanners-OctaveShifts-InvalidSize.musicxml', width: 900 }, - // { filename: '33f-Trill-EndingOnGraceNote.musicxml', width: 900 }, - { filename: '33g-Slur-ChordedNotes.musicxml', width: 900 }, - // { filename: '33h-Spanners-Glissando.musicxml', width: 900 }, - // { filename: '33i-Ties-NotEnded.musicxml', width: 900 }, - { filename: '41a-MultiParts-Partorder.musicxml', width: 900 }, - // { filename: '41b-MultiParts-MoreThan10.musicxml', width: 900 }, - // { filename: '41c-StaffGroups.musicxml', width: 900 }, - // { filename: '41d-StaffGroups-Nested.musicxml', width: 900 }, - // { filename: '41e-StaffGroups-InstrumentNames-Linebroken.musicxml', width: 900 }, - // { filename: '41f-StaffGroups-Overlapping.musicxml', width: 900 }, - // { filename: '41g-PartNoId.musicxml', width: 900 }, - // { filename: '41h-TooManyParts.musicxml', width: 900 }, - // { filename: '41i-PartNameDisplay-Override.musicxml', width: 900 }, - // { filename: '42a-MultiVoice-TwoVoicesOnStaff-Lyrics.musicxml', width: 900 }, - // { filename: '42b-MultiVoice-MidMeasureClefChange.musicxml', width: 900 }, - { filename: '43a-PianoStaff.musicxml', width: 900 }, - // { filename: '43b-MultiStaff-DifferentKeys.musicxml', width: 900 }, - // { filename: '43c-MultiStaff-DifferentKeysAfterBackup.musicxml', width: 900 }, - // { filename: '43d-MultiStaff-StaffChange.musicxml', width: 900 }, - // { filename: '43e-Multistaff-ClefDynamics.musicxml', width: 900 }, - { filename: '45a-SimpleRepeat.musicxml', width: 900 }, - { filename: '45b-RepeatWithAlternatives.musicxml', width: 900 }, - { filename: '45c-RepeatMultipleTimes.musicxml', width: 900 }, - { filename: '45d-Repeats-Nested-Alternatives.musicxml', width: 900 }, - // { filename: '45e-Repeats-Nested-Alternatives.musicxml', width: 900 }, - // { filename: '45f-Repeats-InvalidEndings.musicxml', width: 900 }, - // { filename: '45g-Repeats-NotEnded.musicxml', width: 900 }, - // { filename: '46a-Barlines.musicxml', width: 900 }, - // { filename: '46b-MidmeasureBarline.musicxml', width: 900 }, - // { filename: '46c-Midmeasure-Clef.musicxml', width: 900 }, - // { filename: '46d-PickupMeasure-ImplicitMeasures.musicxml', width: 900 }, - // { filename: '46e-PickupMeasure-SecondVoiceStartsLater.musicxml', width: 900 }, - // { filename: '46f-IncompleteMeasures.musicxml', width: 900 }, - // { filename: '46g-PickupMeasure-Chordnames-FiguredBass.musicxml', width: 900 }, - // { filename: '51b-Header-Quotes.musicxml', width: 900 }, - // { filename: '51c-MultipleRights.musicxml', width: 900 }, - // { filename: '51d-EmptyTitle.musicxml', width: 900 }, - // { filename: '52a-PageLayout.musicxml', width: 900 }, - // { filename: '52b-Breaks.musicxml', width: 900 }, - // { filename: '61a-Lyrics.musicxml', width: 900 }, - // { filename: '61b-MultipleLyrics.musicxml', width: 900 }, - // { filename: '61c-Lyrics-Pianostaff.musicxml', width: 900 }, - // { filename: '61d-Lyrics-Melisma.musicxml', width: 900 }, - // { filename: '61e-Lyrics-Chords.musicxml', width: 900 }, - // { filename: '61f-Lyrics-GracedNotes.musicxml', width: 900 }, - // { filename: '61g-Lyrics-NameNumber.musicxml', width: 900 }, - // { filename: '61h-Lyrics-BeamsMelismata.musicxml', width: 900 }, - // { filename: '61i-Lyrics-Chords.musicxml', width: 900 }, - // { filename: '61j-Lyrics-Elisions.musicxml', width: 900 }, - // { filename: '61k-Lyrics-SpannersExtenders.musicxml', width: 900 }, - // { filename: '71a-Chordnames.musicxml', width: 900 }, - // { filename: '71c-ChordsFrets.musicxml', width: 900 }, - // { filename: '71d-ChordsFrets-Multistaff.musicxml', width: 900 }, - { filename: '71e-TabStaves.musicxml', width: 900 }, - // { filename: '71f-AllChordTypes.musicxml', width: 900 }, - // { filename: '71g-MultipleChordnames.musicxml', width: 900 }, - // { filename: '72a-TransposingInstruments.musicxml', width: 900 }, - // { filename: '72b-TransposingInstruments-Full.musicxml', width: 900 }, - // { filename: '72c-TransposingInstruments-Change.musicxml', width: 900 }, - // { filename: '73a-Percussion.musicxml', width: 900 }, - // { filename: '74a-FiguredBass.musicxml', width: 900 }, - // { filename: '75a-AccordionRegistrations.musicxml', width: 900 }, - // { filename: '99a-Sibelius5-IgnoreBeaming.musicxml', width: 900 }, - // { filename: '99b-Lyrics-BeamsMelismata-IgnoreBeams.musicxml', width: 900 }, - ])(`$filename ($width px)`, async (t) => { - const { document, vexmlDiv, screenshotElementSelector } = setup(); - - const buffer = fs.readFileSync(path.join(DATA_DIR, t.filename)); - const musicXML = buffer.toString(); - vexml.renderMusicXML(musicXML, vexmlDiv, { config: { WIDTH: t.width } }); - - await page.setViewport({ - width: t.width, - // height doesn't matter since we screenshot the element, not the page. - height: 0, - }); - await page.setContent(document.documentElement.outerHTML); - - const element = await page.$(screenshotElementSelector); - const screenshot = Buffer.from((await element!.screenshot()) as any); - expect(screenshot).toMatchImageSnapshot({ - customSnapshotIdentifier: getSnapshotIdentifier({ filename: t.filename, width: t.width }), - }); - }); -}); diff --git a/tests/integration/render.test.ts b/tests/integration/render.test.ts new file mode 100644 index 000000000..098089e61 --- /dev/null +++ b/tests/integration/render.test.ts @@ -0,0 +1,521 @@ +import { expect, test } from 'bun:test'; +import { render } from '../testing/harness'; +import { testCase } from '../testing/test-case'; + +const TEST_CASES = [ + // A single empty 5-line stave: staff lines with start and end barlines, nothing else. + testCase('structure_single_stave.musicxml', 'structure_single_stave.png'), + + // One part with two empty staves joined by a curly brace (grand staff). + testCase('structure_grand_staff.musicxml', 'structure_grand_staff.png'), + + // Two separate single-stave parts stacked vertically, with no connecting brace. + testCase('structure_two_parts.musicxml', 'structure_two_parts.png'), + + // A single-stave part above a two-stave (braced) part — mixed stave counts. + testCase('structure_mixed_staves.musicxml', 'structure_mixed_staves.png'), + + // A guitar split across two single-stave parts — a treble notation stave (P1) + // above a 6-line TAB stave (P2) — joined by a bracket plus the system's left line. + // The notation+tab pairing is bracketed by convention even when the two staves + // live in separate parts rather than one two-stave part. An ascending E4/F4/G4/A4 + // line on string 1 (frets 0/1/3/5) appears as notation on top and matching frets + // below. + testCase( + 'structure_notation_and_tab_parts.musicxml', + 'structure_notation_and_tab_parts.png', + ), + + // A single-stave part above a two-stave (braced) part, each with its instrument + // name printed to the left of the first system (showPartLabels): "Violin" + // centered on the single top stave, "Piano" centered on the braced pair. + testCase('structure_part_labels.musicxml', 'structure_part_labels.png', { + showPartLabels: true, + }), + + // Same two labelled parts as structure_part_labels, but with the text font + // overridden to Times New Roman (fonts.text). The two instrument names render in + // that family instead of the default Source Sans 3, proving the text FontConfig option + // flows through to the part labels (the text vexml draws in the margin). + testCase('structure_part_labels.musicxml', 'font_text.png', { + showPartLabels: true, + fonts: { text: { family: 'Times New Roman' } }, + }), + + // Treble stave, 4/4, one measure (two quarters, two flagged eighths, a quarter + // rest, all on C5), engraved with VexFlow's Petaluma font instead of the default + // Bravura (fonts.notation). The notehead, stem flags, treble clef, and rest glyph + // all take Petaluma's rounder, hand-drawn shapes — proving the notation FontConfig + // option swaps the engraving font. + testCase('font_notation_petaluma.musicxml', 'font_notation_petaluma.png', { + fonts: { notation: { family: 'Petaluma' } }, + }), + + // A single empty stave with a treble (G) clef. + testCase('clef_treble.musicxml', 'clef_treble.png'), + + // Grand staff: treble clef on the upper stave, bass clef on the lower, joined by a + // brace. + testCase('clef_treble_bass.musicxml', 'clef_treble_bass.png'), + + // A 6-line tablature stave with a stacked "TAB" label at the left. With no + // other stave to connect to, the lone TAB stave draws its own begin barline. + testCase('clef_tab_6_string.musicxml', 'clef_tab_6_string.png'), + + // A 4-line tablature stave with a stacked "TAB" label at the left. With no + // other stave to connect to, the lone TAB stave draws its own begin barline. + testCase('clef_tab_4_string.musicxml', 'clef_tab_4_string.png'), + + // A treble notation stave above a 6-line TAB stave, joined by a bracket (the + // notation+tab convention, applied automatically with no declared). + // 3-sharp key and 4/4 time: both print on the notation stave only — the TAB stave + // shows neither key signature nor time signature, just its stacked "TAB" glyph. + testCase('clef_notation_and_tab.musicxml', 'clef_notation_and_tab.png'), + + // Guitar: a treble notation stave over a 6-line TAB stave joined by a bracket, here + // stated explicitly via bracket (the same connector the + // pairing gets by default). 4/4, an ascending line on string 1 — notation E4/F4/G4/A4 + // quarters sitting on the treble staff, with the matching TAB frets 0/1/3/5 below, + // proving the fret -> pitch mapping. + testCase( + 'clef_notation_and_tab_bracket.musicxml', + 'clef_notation_and_tab_bracket.png', + ), + + // One system, treble 4/4: key signatures and a mid-system key change. Each measure + // holds one C5 whole note. + // - M1: opens the system with a treble clef, a 3-sharp key signature (F#, C#, G#), + // and a 4/4 time signature. + // - M2: changes the key to 2 flats (Bb, Eb) — only the new key signature is redrawn + // at the change (the clef and time signature are NOT repeated). + // - M3: continues in 2 flats with no key signature redrawn. + testCase('key.musicxml', 'key.png'), + + // One system, treble: time signatures and mid-system meter changes. + // - M1: opens the system with a treble clef and common time (the "C" symbol = 4/4); + // four C5 quarters. + // - M2: changes the meter to cut time (the "¢" symbol = 2/2) — only the new time + // signature is redrawn (the clef is NOT repeated); two C5 half notes. + // - M3: changes the meter to a numeric 3/4 (stacked numerals); three C5 quarters. + // - M4: continues in 3/4 with no time signature redrawn; three C5 quarters. + testCase('time.musicxml', 'time.png'), + + // Treble stave, 4/4: single-note rendering — durations then stem direction. + // - M1: a whole note (C5). + // - M2: a half, quarter, eighth, sixteenth, then two thirty-seconds on C5 (increasing + // flag counts). + // - M3: stem direction by staff position (no ) — the treble middle line is B4, + // so E4 and G4 stem up while B4 and D5 stem down. + // - M4: the same four pitches with an explicit overriding each position default + // — E4 down, G4 down, B4 up, D5 up. + testCase('note.musicxml', 'note.png'), + + // Treble stave, 4/4, all on C5: note density per measure (beat counts deliberately + // ignored). Each measure varies the number and kind of notes. Under the logarithmic + // spacing model a measure's width tracks its note *count* (with a weak pull from note + // value), so denser measures are wider — the opposite of a fixed px-per-tick, which + // would make every 16-tick measure equally wide. Wraps to three systems (M1-3, M4-5, + // M6); complete systems are justified to full width. + // - M1: one whole note (1 event) — floors at the minimum width, the narrowest measure. + // - M2: four quarters (4 events) — wider than M1. + // - M3: eight beamed eighths (8 events) — wider still; M1-M3 share the first system. + // - M4: sixteen beamed sixteenths (16 events) — the densest, so the widest natural + // width; leads the second system. + // - M5: eight quarters (8 events) — shares the second system with M4. + // - M6: mixed kinds in one measure — quarter, two beamed eighths, four beamed + // sixteenths, then a half. Trailing system, left unjustified at its natural width, so + // the uneven within-measure spacing (wide quarter, then progressively tighter) shows. + testCase('note_density.musicxml', 'note_density.png'), + + // Treble stave, 4/4, all on C5: dotted-note variations. + // - M1: dotted-quarter + eighth pairs (single dots). + // - M2: double-dotted-quarter + sixteenth pairs (double dots). + // - M3: four beamed dotted-eighth + sixteenth pairs (dots inside beams). + testCase('dotted_notes.musicxml', 'dotted_notes.png'), + + // Treble stave, 4/4: the rest counterpart of note.musicxml's durations (M1-M2). + // - M1: a whole rest, centered horizontally in the measure (full-measure-rest convention). + // - M2: half, quarter, eighth, sixteenth, then two thirty-second rests. + testCase('rest.musicxml', 'rest.png'), + + // Treble stave, 4/4: four C5 quarter notes at one staff position — sharp, flat, + // natural, then no accidental — so only the accidental glyph varies. + testCase('accidentals.musicxml', 'accidentals.png'), + + // Treble stave, 4/4: metronome marks from , drawn above the + // staff just right of the time signature (" = bpm"). + // - M1: an explicit quarter = 120 over four B4 quarters (mid-staff, no collision) — + // the mark sits one text line above the staff. + // - M2: quarter = 120 over a high first note (C6, two ledger lines above) that reaches + // up into the mark's default band, so the mark is lifted clear of the notehead. + testCase('tempo.musicxml', 'tempo.png'), + + // Treble stave, 4/4: two measures split by a barline, each holding one whole note + // (C5, same pitch in both). + testCase('measures_two.musicxml', 'measures_two.png'), + + // Grand staff (empty treble over empty bass), two measures. Because the system has + // multiple staves, the per-stave end barlines are suppressed and the dividing lines + // are drawn entirely by stave connectors. + // - M1: closes with the internal barline between measures — a single thin line + // spanning both staves (the singleRight connector). + // - M2: the piece's final measure closes with a bold thin-thick double line spanning + // both staves (boldDoubleRight) rather than the plain single line drawn at every + // other measure end. + testCase('measures_end_barline.musicxml', 'measures_end_barline.png'), + + // Beam variations across seven 4/4 measures. Wraps across systems. + // - M1: simple beamed eighths in a small range. + // - M2: beamed eighths leaping a wide range (steep beams, ledger lines above on + // D6/E6 and below on C4/D4). + // - M3: two double-beamed sixteenth groups then a half rest. + // - M4: one beat of triple-beamed 32nds then half + quarter rests. + // - M5: mixed eighth+sixteenth beats with partial secondary beams. + // - M6: a beamed eighth group spanning an internal eighth rest. + // - M7: beamed eighths in a low range (below the middle line) so the auto stem + // direction flips up. + testCase('beam_variations.musicxml', 'beam_variations.png'), + + // Treble stave, 4/4: four quarter-note chords — a C5/E5/G5 triad, a C5/D5 second + // (offset noteheads), a C5/D5/E5 cluster, then a C5/E5/G5/A5 chord with a second + // (G5/A5) on top. + testCase('chord.musicxml', 'chord.png'), + + // Treble stave, 3/4: an ascending run of quarter notes covering every natural + // pitch from F3 (three ledger lines below the staff) up to E6 (three ledger lines + // above), three notes per measure across seven measures — ledger lines grow from + // three below, shrink to none on the staff, then grow to three above. Wraps across + // systems. + testCase('ledger_lines.musicxml', 'ledger_lines.png'), + + // Treble stave, 4/4: ties on single notes (the tied-chord variants live in the + // tie_chord_* fixtures below). + // - M1: two half notes tied within the measure. + // - M2-3: a whole note tied into the next whole note across a system break — M2 ends one + // system and M3 begins the next, so the tie splits into two partial arcs: one bowing off + // the right edge of M2 ("tie to nothing") and one bowing in from the left edge of M3 + // ("tie from nothing"), rather than one line slanting down across the page. + testCase('tie.musicxml', 'tie.png'), + + // Treble stave, 4/4, one measure: two stem-up half-note chords (C5/E5/G5) with all three + // members tied — the bottom member (C5) bows under (concave up) and the upper two (E5, G5) + // bow over (concave down), sandwiching the chord while the over-arcs clear the up-stems. + testCase('tie_chord_triad.musicxml', 'tie_chord_triad.png'), + + // Treble stave, 4/4, one measure: a two-note chord (C5/E5) with both members tied — the + // lower bows under (concave up), the upper bows over (concave down), so the ties diverge + // from the chord center. + testCase('tie_chord_dyad.musicxml', 'tie_chord_dyad.png'), + + // Treble stave, 4/4, one measure: a four-note chord (C5/E5/G5/C6) with all members tied — + // the lower half (C5, E5) bows under and the upper half (G5, C6) bows over, a two-under / + // two-over split across a one-octave spread. + testCase('tie_chord_octave.musicxml', 'tie_chord_octave.png'), + + // Treble stave, 4/4, one measure: spacing variant — a two-note second (C5/D5) with both + // members tied; the second offsets the noteheads across the stem, C5 bowing under and D5 + // over. + testCase('tie_chord_second.musicxml', 'tie_chord_second.png'), + + // Treble stave, 4/4, one measure: spacing variant — a four-note cluster of stacked seconds + // (C5/D5/E5/F5) with all members tied; zig-zag offset noteheads, lower half under and upper + // half over. + testCase('tie_chord_cluster.musicxml', 'tie_chord_cluster.png'), + + // Treble stave, 4/4: four quarters C5, D5, E5, F5 under one slur with no placement + // attribute (default). The stem-down notes push the slur above the noteheads. + testCase('slur_default.musicxml', 'slur_default.png'), + + // Treble stave, 4/4: four quarters G5, A5, B5, A5 under one slur with explicit + // placement="above" — the slur arcs above the noteheads. + testCase('slur_above.musicxml', 'slur_above.png'), + + // Treble stave, 4/4: one slur beneath an ascending low line E4, F4, G4, A4. All + // notes sit below the middle line so their stems point up, and the slur bows below + // the noteheads (opposite side from the stems). + testCase('slur_stem_up.musicxml', 'slur_stem_up.png'), + + // Treble stave, 4/4: one slur over a zig-zag line C5, G4, D5, A4 straddling the + // middle line, so the stems alternate down-up-down-up. The slur arcs above, clear of + // both the noteheads and the up-stem tips. + testCase('slur_mixed_stems.musicxml', 'slur_mixed_stems.png'), + + // Treble stave, 4/4: two half notes A5 and C4 slurred across a wide downward leap — + // the slur spans the measure between the distant noteheads. + testCase('slur_leap.musicxml', 'slur_leap.png'), + + // Treble stave, 4/4: eight beamed eighths (two four-note beams) under a single slur + // arcing above the whole beamed run. + testCase('slur_beamed.musicxml', 'slur_beamed.png'), + + // Treble stave, 4/4: four quarters carrying two separate two-note slurs (C5-D5 and + // E5-D5) using distinct slur numbers — two short independent arcs above. + testCase('slur_multiple.musicxml', 'slur_multiple.png'), + + // Treble stave, 4/4: three chained slurs over E4, G4, E5, C5 — a slur below the + // first pair (E4-G4, stem-up), a slur bridging note 2 to note 3 (G4-E5) below, and a + // slur above the last pair (E5-C5, stem-down). Overlapping slurs use distinct + // numbers, so notes 2 and 3 each carry both a stop and a start. + testCase('slur_chained.musicxml', 'slur_chained.png'), + + // 6-line TAB stave, half notes: hammer-ons and pull-offs notated with plain + // s, the "H"/"P" label inferred from fret motion (higher target = hammer-on, + // lower = pull-off). No