Add a New line button for another fingering row.
AppImage release / appimage (push) Successful in 49s

Each line keeps its own charts and staff. Save PNG stacks every line. Version 0.2.3.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
Cursor Agentandchen committed 2026-10-11 10:14:28 +00:00
1 parent e51fdf8216
commit 7c988e9347
7 files changed
+246 -66

No files matched your search

+5 -5
View File
@@ -7,17 +7,17 @@ The usual way to run it is one Linux AppImage. That file contains the window and
## Run the AppImage
```bash
chmod +x Recorder-Tab-Converter-0.2.2.AppImage
./Recorder-Tab-Converter-0.2.2.AppImage
chmod +x Recorder-Tab-Converter-0.2.3.AppImage
./Recorder-Tab-Converter-0.2.3.AppImage
```
If the file will not start because FUSE is missing:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.2.AppImage
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.3.AppImage
```
Open **Empty line**, or start from **Twinkle, opening** or **Ode, held note**. Drag any chart from C5 to D7, or drag **Rest** or **Space**, onto the line. Drag a placed item to move it. Choose a duration under it. Remove takes it off. **Shorter**, **Taller**, **Narrower**, **Wider**, and the corner grip change the line box. **Save PNG** downloads the finished row.
Open **Empty line**, or start from **Twinkle, opening** or **Ode, held note**. Drag any chart from C5 to D7, or drag **Rest** or **Space**, onto a line. Drag a placed item to move it, including onto another line. **New line** adds a row underneath. Plus and minus change the length. Remove takes an item off. **Shorter**, **Taller**, **Narrower**, **Wider**, and the corner grip change the box. **Save PNG** downloads every line.
## Build the AppImage
@@ -30,7 +30,7 @@ From a checkout, one command:
That installs Node packages, builds the app, and writes:
```text
dist/Recorder-Tab-Converter-0.2.2.AppImage
dist/Recorder-Tab-Converter-0.2.3.AppImage
```
The AppImage is not committed. Keep it under 600MB.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "recorder-tab-converter",
"version": "0.2.2",
"version": "0.2.3",
"private": true,
"description": "Arrange soprano recorder fingering charts by dragging them onto a line.",
"main": "electron/main.cjs",
+124 -46
View File
@@ -6,20 +6,23 @@ import { DURATION_PRESETS } from "@/lib/duration";
import { downloadBlob, renderLinePng } from "@/lib/export-png";
import { CHART_NOTES, fingeringFor } from "@/lib/fingering";
import {
appendLine,
clampLineBox,
cloneLine,
createNote,
createRest,
createSpace,
dropIndexFromPoint,
insertNote,
LINE_EXAMPLES,
moveNote,
neighborDuration,
placeOnLine,
removeLine,
removeNote,
setDuration,
transferNote,
type LineKind,
type LineNote,
type ScoreLine,
} from "@/lib/line";
import { chartPixelHeight } from "@/lib/notation";
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
@@ -33,6 +36,7 @@ type Drag = {
kind: LineKind;
pitch: string;
id: string;
lineId: string;
pointerId: number;
startX: number;
startY: number;
@@ -41,17 +45,18 @@ type Drag = {
armed: boolean;
};
type DropTarget = { lineId: string; index: number };
export function Editor() {
const [notes, setNotes] = useState<LineNote[]>([]);
const [lines, setLines] = useState<ScoreLine[]>([{ id: "line-1", notes: [] }]);
const [title, setTitle] = useState("Empty line");
const [drag, setDrag] = useState<Drag | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [boxWidth, setBoxWidth] = useState<number | null>(null);
const [boxHeight, setBoxHeight] = useState(520);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const dragRef = useRef<Drag | null>(null);
const lineRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const paletteRef = useRef<HTMLDivElement>(null);
const exportRef = useRef<HTMLDivElement>(null);
@@ -63,15 +68,23 @@ export function Editor() {
}
useEffect(() => {
function pointIndex(clientX: number, clientY: number) {
const line = lineRef.current;
if (!line) return -1;
const rect = line.getBoundingClientRect();
const centers = [...line.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
function pointTarget(clientX: number, clientY: number): DropTarget | null {
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
let best: (DropTarget & { distance: number }) | null = null;
for (const row of rows) {
const lineId = row.dataset.lineId;
if (!lineId) continue;
const rect = row.getBoundingClientRect();
const centers = [...row.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
const box = element.getBoundingClientRect();
return box.left + box.width / 2;
});
return dropIndexFromPoint(clientX, clientY, rect, centers);
const index = dropIndexFromPoint(clientX, clientY, rect, centers);
if (index < 0) continue;
const distance = Math.abs(clientY - (rect.top + rect.bottom) / 2);
if (!best || distance < best.distance) best = { lineId, index, distance };
}
return best ? { lineId: best.lineId, index: best.index } : null;
}
function onMove(event: PointerEvent) {
@@ -87,28 +100,30 @@ export function Editor() {
Math.abs(dx) > Math.abs(dy);
if (touchScroll) {
updateDrag(null);
setDropIndex(null);
setDropTarget(null);
return;
}
if (Math.hypot(dx, dy) < 8) return;
}
window.getSelection()?.removeAllRanges();
if (lineRef.current) {
const rect = lineRef.current.getBoundingClientRect();
if (event.clientX > rect.right - 28) lineRef.current.scrollLeft += 14;
if (event.clientX < rect.left + 28) lineRef.current.scrollLeft -= 14;
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
for (const candidate of rows) {
const rect = candidate.getBoundingClientRect();
const verticallyNear = event.clientY >= rect.top - 36 && event.clientY <= rect.bottom + 36;
if (!verticallyNear) continue;
if (event.clientX > rect.right - 28) candidate.scrollLeft += 14;
if (event.clientX < rect.left + 28) candidate.scrollLeft -= 14;
}
const next = { ...current, x: event.clientX, y: event.clientY, armed: true };
updateDrag(next);
const index = pointIndex(event.clientX, event.clientY);
setDropIndex(index >= 0 ? index : null);
setDropTarget(pointTarget(event.clientX, event.clientY));
}
function onUp(event: PointerEvent) {
const current = dragRef.current;
if (!current || event.pointerId !== current.pointerId) return;
const index = current.armed ? pointIndex(event.clientX, event.clientY) : -1;
if (current.armed && index >= 0) {
const target = current.armed ? pointTarget(event.clientX, event.clientY) : null;
if (current.armed && target) {
if (current.source === "palette") {
const note =
current.kind === "rest"
@@ -116,14 +131,14 @@ export function Editor() {
: current.kind === "space"
? createSpace()
: createNote(current.pitch);
setNotes((existing) => insertNote(existing, index, note));
setLines((existing) => placeOnLine(existing, target.lineId, target.index, note));
setTitle("Your line");
} else {
setNotes((existing) => moveNote(existing, current.id, index));
setLines((existing) => transferNote(existing, current.lineId, current.id, target.lineId, target.index));
}
}
updateDrag(null);
setDropIndex(null);
setDropTarget(null);
}
function onSelectStart(event: Event) {
@@ -194,7 +209,8 @@ export function Editor() {
}
async function savePng() {
if (!exportRef.current || notes.length === 0) {
const filled = lines.some((line) => line.notes.length > 0);
if (!exportRef.current || !filled) {
setSaveError("Add a note, rest, or space before saving a PNG.");
return;
}
@@ -218,18 +234,19 @@ export function Editor() {
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · Baroque</p>
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder fingering editor</h1>
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
Drag a chart, a rest, or a space onto the line. Drag a placed item sideways to reorder it.
The staff under the holes shows the pitch and the length. Resize the line box, then save the
finished row as a PNG. The foot, with the double holes, stays at the top.
Drag a chart, a rest, or a space onto a line. Drag a placed item sideways to reorder it, or
onto another line. New line adds a row underneath. The staff under the holes shows the pitch
and the length. Resize the box, then save every line as a PNG. The foot, with the double holes,
stays at the top.
</p>
</header>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={notes.length === 0 ? "default" : "outline"}
variant={lines.length === 1 && lines[0].notes.length === 0 ? "default" : "outline"}
onClick={() => {
setNotes([]);
setLines([{ id: "line-1", notes: [] }]);
setTitle("Empty line");
}}
>
@@ -241,7 +258,7 @@ export function Editor() {
type="button"
variant="outline"
onClick={() => {
setNotes(cloneLine([...example.notes]));
setLines([{ id: "line-1", notes: cloneLine([...example.notes]) }]);
setTitle(example.title);
}}
>
@@ -273,6 +290,7 @@ export function Editor() {
kind: "rest",
pitch: "",
id: "",
lineId: "",
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
@@ -292,6 +310,7 @@ export function Editor() {
kind: "space",
pitch: "",
id: "",
lineId: "",
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
@@ -314,6 +333,7 @@ export function Editor() {
kind: "note",
pitch,
id: "",
lineId: "",
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
@@ -339,7 +359,15 @@ export function Editor() {
{boxWidth ? `, ${boxWidth}px wide` : ""}.
</p>
</div>
<ScrollButtons target={lineRef} label="line" />
<ScrollButtons
label="lines"
onLeft={() =>
document.querySelectorAll<HTMLElement>("[data-line-id]").forEach((row) => row.scrollBy({ left: -180, behavior: "smooth" }))
}
onRight={() =>
document.querySelectorAll<HTMLElement>("[data-line-id]").forEach((row) => row.scrollBy({ left: 180, behavior: "smooth" }))
}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, -48)}>
@@ -357,28 +385,56 @@ export function Editor() {
<Button type="button" variant="outline" size="sm" onClick={() => setBoxWidth(null)}>
Fit width
</Button>
<Button type="button" size="sm" onClick={savePng} disabled={notes.length === 0 || saving}>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setLines((existing) => appendLine(existing));
setBoxHeight((height) => clampLineBox(400, height + 260, 960).height);
}}
>
New line
</Button>
<Button type="button" size="sm" onClick={savePng} disabled={lines.every((line) => line.notes.length === 0) || saving}>
{saving ? "Saving…" : "Save PNG"}
</Button>
</div>
{saveError ? <p className="text-sm text-destructive">{saveError}</p> : null}
<div ref={frameRef} className="relative max-w-full" style={{ width: boxWidth ?? "100%", height: boxHeight }}>
<div
ref={lineRef}
data-line
className={`h-full overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
dropIndex !== null ? "border-primary" : "border-foreground"
className={`flex h-full flex-col gap-4 overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
dropTarget !== null ? "border-primary" : "border-foreground"
}`}
>
{notes.length === 0 ? (
<p className="px-2 py-16 text-center text-sm text-muted-foreground">
{lines.map((line, lineIndex) => (
<div key={line.id} className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">Line {lineIndex + 1}</p>
{lines.length > 1 ? (
<Button
type="button"
variant="ghost"
size="xs"
aria-label={`Remove line ${lineIndex + 1}`}
onClick={() => setLines((existing) => removeLine(existing, line.id))}
>
<X />
Remove line
</Button>
) : null}
</div>
<div data-line-id={line.id} className="overflow-x-auto">
{line.notes.length === 0 ? (
<p className="px-2 py-10 text-center text-sm text-muted-foreground">
Drop a fingering, a rest, or a space here. The line is empty.
</p>
) : (
<div className="flex w-max min-w-full items-end gap-2">
{notes.map((note, index) => (
{line.notes.map((note, index) => (
<div key={note.id} className="flex items-end gap-2">
{dropIndex === index ? <DropCaret /> : null}
{dropTarget?.lineId === line.id && dropTarget.index === index ? <DropCaret /> : null}
<PlacedItem
note={note}
dimmed={drag?.source === "line" && drag.armed && drag.id === note.id}
@@ -387,6 +443,7 @@ export function Editor() {
source: "line",
kind: note.kind,
id: note.id,
lineId: line.id,
pitch: note.pitch,
pointerId: event.pointerId,
startX: event.clientX,
@@ -399,16 +456,29 @@ export function Editor() {
onDuration={(quarters) => {
const preset = DURATION_PRESETS.find((item) => item.quarters === quarters);
if (!preset) return;
setNotes((existing) => setDuration(existing, note.id, preset));
setLines((existing) =>
existing.map((row) =>
row.id === line.id ? { ...row, notes: setDuration(row.notes, note.id, preset) } : row,
),
);
}}
onRemove={() => setNotes((existing) => removeNote(existing, note.id))}
onRemove={() =>
setLines((existing) =>
existing.map((row) =>
row.id === line.id ? { ...row, notes: removeNote(row.notes, note.id) } : row,
),
)
}
/>
</div>
))}
{dropIndex === notes.length ? <DropCaret /> : null}
{dropTarget?.lineId === line.id && dropTarget.index === line.notes.length ? <DropCaret /> : null}
</div>
)}
</div>
</div>
))}
</div>
<div
role="slider"
tabIndex={0}
@@ -457,7 +527,9 @@ export function Editor() {
) : null}
<div ref={exportRef} className="pointer-events-none absolute h-px w-px overflow-hidden" aria-hidden>
{notes.map((note) => (
{lines.map((line) => (
<div key={line.id} data-export-line={line.id}>
{line.notes.map((note) => (
<div
key={note.id}
data-export-slot
@@ -478,6 +550,8 @@ export function Editor() {
</div>
))}
</div>
))}
</div>
</div>
);
}
@@ -597,9 +671,13 @@ function DropCaret() {
function ScrollButtons({
target,
label,
onLeft,
onRight,
}: {
target: RefObject<HTMLDivElement | null>;
target?: RefObject<HTMLDivElement | null>;
label: string;
onLeft?: () => void;
onRight?: () => void;
}) {
return (
<div className="flex shrink-0 gap-1">
@@ -608,7 +686,7 @@ function ScrollButtons({
variant="outline"
size="icon-sm"
aria-label={`Scroll ${label} left`}
onClick={() => target.current?.scrollBy({ left: -180, behavior: "smooth" })}
onClick={() => onLeft?.() ?? target?.current?.scrollBy({ left: -180, behavior: "smooth" })}
>
<ChevronLeft />
</Button>
@@ -617,7 +695,7 @@ function ScrollButtons({
variant="outline"
size="icon-sm"
aria-label={`Scroll ${label} right`}
onClick={() => target.current?.scrollBy({ left: 180, behavior: "smooth" })}
onClick={() => onRight?.() ?? target?.current?.scrollBy({ left: 180, behavior: "smooth" })}
>
<ChevronRight />
</Button>
+32 -13
View File
@@ -1,4 +1,4 @@
import { pngItemFromNote, pngLayout, type PngItem } from "@/lib/png-layout";
import { pngItemFromNote, pngStackLayout, type PngItem } from "@/lib/png-layout";
const INK = "#1a1a1a";
const PAPER = "#fbf6ee";
@@ -23,8 +23,8 @@ function loadSvg(svg: SVGElement): Promise<HTMLImageElement> {
});
}
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
const slots = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-slot]")];
async function readRow(line: HTMLElement): Promise<{ items: PngItem[]; images: Array<HTMLImageElement | null> } | null> {
const slots = [...line.querySelectorAll<HTMLElement>(":scope > [data-export-slot]")];
const items: PngItem[] = [];
const images: Array<HTMLImageElement | null> = [];
for (const slot of slots) {
@@ -41,9 +41,24 @@ export async function renderLinePng(exportRoot: HTMLElement, title: string): Pro
const svg = slot.querySelector("svg");
images.push(svg ? await loadSvg(svg) : null);
}
if (items.length === 0) throw new Error("Add a note, rest, or space before saving a PNG.");
if (items.length === 0) return null;
return { items, images };
}
const layout = pngLayout(items, title);
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
const lineNodes = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-line]")];
const sources = lineNodes.length > 0 ? lineNodes : [exportRoot];
const rows: Array<{ items: PngItem[]; images: Array<HTMLImageElement | null> }> = [];
for (const line of sources) {
const row = await readRow(line);
if (row) rows.push(row);
}
if (rows.length === 0) throw new Error("Add a note, rest, or space before saving a PNG.");
const layout = pngStackLayout(
rows.map((row) => row.items),
title,
);
const scale = 2;
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(layout.width * scale);
@@ -60,26 +75,30 @@ export async function renderLinePng(exportRoot: HTMLElement, title: string): Pro
ctx.font = "600 22px Georgia, serif";
ctx.fillText(title, layout.pad, layout.pad + 40);
const glyphTop = layout.pad + 56;
let glyphTop = layout.pad + 56;
rows.forEach((row, rowIndex) => {
const laid = layout.rows[rowIndex];
let x = layout.pad;
items.forEach((item, index) => {
const slotWidth = layout.slots[index]?.width ?? item.glyphWidth;
row.items.forEach((item, index) => {
const slotWidth = laid?.slots[index]?.width ?? item.glyphWidth;
if (item.kind === "space") {
ctx.save();
ctx.strokeStyle = SPACE_STROKE;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), layout.glyphHeight - 16);
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), laid.glyphHeight - 16);
ctx.restore();
} else if (images[index]) {
ctx.drawImage(images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
} else if (row.images[index]) {
ctx.drawImage(row.images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
}
const center = x + item.glyphWidth / 2;
ctx.textAlign = "center";
ctx.fillStyle = INK;
ctx.font = "600 13px sans-serif";
ctx.fillText(item.caption, center, glyphTop + layout.glyphHeight + 18);
ctx.fillText(item.caption, center, glyphTop + laid.glyphHeight + 18);
ctx.textAlign = "start";
x += slotWidth + layout.gap;
x += slotWidth + laid.gap;
});
glyphTop += laid.glyphHeight + 28 + layout.rowGap;
});
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
+46
View File
@@ -86,6 +86,52 @@ export function cloneLine(notes: LineNote[]): LineNote[] {
return notes.map((note) => ({ ...note, id: newId("n") }));
}
export type ScoreLine = {
id: string;
notes: LineNote[];
};
export function appendLine(lines: ScoreLine[], id = newId("line")): ScoreLine[] {
return [...lines, { id, notes: [] }];
}
/** Leave the last line in place. An empty score still has one row. */
export function removeLine(lines: ScoreLine[], id: string): ScoreLine[] {
if (lines.length <= 1) return lines;
const next = lines.filter((line) => line.id !== id);
return next.length > 0 ? next : lines;
}
function mapLine(lines: ScoreLine[], id: string, update: (notes: LineNote[]) => LineNote[]): ScoreLine[] {
return lines.map((line) => (line.id === id ? { ...line, notes: update(line.notes) } : line));
}
export function placeOnLine(lines: ScoreLine[], lineId: string, index: number, note: LineNote): ScoreLine[] {
return mapLine(lines, lineId, (notes) => insertNote(notes, index, note));
}
/**
* Move a note onto a line. When it stays on the same line, `toIndex` counts the moving note.
* When it changes lines, `toIndex` is the slot in the destination.
*/
export function transferNote(
lines: ScoreLine[],
fromLineId: string,
noteId: string,
toLineId: string,
toIndex: number,
): ScoreLine[] {
const source = lines.find((line) => line.id === fromLineId);
const note = source?.notes.find((item) => item.id === noteId);
if (!source || !note) return lines;
if (fromLineId === toLineId) return mapLine(lines, fromLineId, (notes) => moveNote(notes, noteId, toIndex));
return lines.map((line) => {
if (line.id === fromLineId) return { ...line, notes: removeNote(line.notes, noteId) };
if (line.id === toLineId) return { ...line, notes: insertNote(line.notes, toIndex, note) };
return line;
});
}
/** -1 when the pointer is outside the line. Otherwise the insertion slot from left to right. */
export function dropIndexFromPoint(
pointerX: number,
+11
View File
@@ -35,6 +35,17 @@ export function pngSlotWidth(item: PngItem): number {
return Math.max(item.glyphWidth, text, 48);
}
export const PNG_ROW_GAP = 36;
/** One header, then every fingering line stacked underneath. */
export function pngStackLayout(rows: PngItem[][], title: string) {
const laid = rows.map((items) => pngLayout(items, title));
const width = Math.max(1, ...laid.map((row) => row.width));
const blocks = laid.reduce((sum, row) => sum + row.glyphHeight + 28, 0);
const height = PAD + 44 + blocks + PNG_ROW_GAP * Math.max(0, laid.length - 1) + PAD;
return { width, height, rows: laid, pad: PAD, rowGap: PNG_ROW_GAP };
}
export function pngLayout(items: PngItem[], title: string) {
const slots = items.map((item) => ({ item, width: pngSlotWidth(item) }));
const content =
+27 -1
View File
@@ -10,10 +10,13 @@ import {
dropIndexFromPoint,
insertNote,
LINE_EXAMPLES,
appendLine,
moveNote,
neighborDuration,
removeLine,
removeNote,
setDuration,
transferNote,
} from "@/lib/line";
import {
ledgerIndexes,
@@ -23,7 +26,7 @@ import {
stemDown,
} from "@/lib/notation";
import { parsePitch } from "@/lib/pitch";
import { pngFilename, pngItemFromNote, pngLayout } from "@/lib/png-layout";
import { pngFilename, pngItemFromNote, pngLayout, pngStackLayout } from "@/lib/png-layout";
import { extraBeatArrows } from "@/lib/score";
const BAROQUE: Record<string, string> = {
@@ -167,6 +170,29 @@ describe("fingering line", () => {
expect(pngFilename(" ")).toBe("line.png");
});
it("appends a line and can move a note onto it", () => {
const top = { id: "a", notes: [createNote("C5", quarter, "c"), createNote("E5", quarter, "e")] };
const next = appendLine([top], "b");
expect(next.map((line) => line.id)).toEqual(["a", "b"]);
expect(next[1].notes).toEqual([]);
expect(next[0].notes.map((note) => note.pitch)).toEqual(["C5", "E5"]);
const moved = transferNote(next, "a", "e", "b", 0);
expect(moved[0].notes.map((note) => note.pitch)).toEqual(["C5"]);
expect(moved[1].notes.map((note) => note.pitch)).toEqual(["E5"]);
expect(removeLine(moved, "b").map((line) => line.id)).toEqual(["a"]);
expect(removeLine([{ id: "a", notes: [] }], "a")).toHaveLength(1);
});
it("stacks every fingering line in the PNG", () => {
const top = [pngItemFromNote(createNote("C5", quarter, "c"))];
const bottom = [pngItemFromNote(createNote("G5", quarter, "g"))];
const one = pngStackLayout([top], "Your line");
const two = pngStackLayout([top, bottom], "Your line");
expect(two.rows).toHaveLength(2);
expect(two.height).toBeGreaterThan(one.height);
expect(pngLayout(top, "Your line").glyphHeight).toBe(one.rows[0].glyphHeight);
});
it("steps duration without skipping a preset", () => {
expect(neighborDuration(1, 1)?.quarters).toBe(1.5);
expect(neighborDuration(1, -1)?.quarters).toBe(0.75);