Show length on a staff under each fingering chart.
AppImage release / appimage (push) Successful in 50s

Dragging no longer selects text. The line box resizes, rests and spaces can be added, and Save PNG writes the row. Each chart keeps the foot at the top, and the treble staff shows the pitch with a notehead, stem, flags, and dots. Version 0.2.1.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
Cursor Agentandchen committed 2026-10-11 09:49:17 +00:00
1 parent b354ea9f88
commit dcca7f5e83
11 files changed
+997 -177

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
import { pngItemFromNote, pngLayout, type PngItem } from "@/lib/png-layout";
const INK = "#1a1a1a";
const PAPER = "#fbf6ee";
const SPACE_STROKE = "#c4b8a8";
function loadSvg(svg: SVGElement): Promise<HTMLImageElement> {
const clone = svg.cloneNode(true) as SVGElement;
if (!clone.getAttribute("xmlns")) clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
const xml = new XMLSerializer().serializeToString(clone);
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Could not draw a chart."));
};
image.src = url;
});
}
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
const slots = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-slot]")];
const items: PngItem[] = [];
const images: Array<HTMLImageElement | null> = [];
for (const slot of slots) {
const kind = slot.dataset.kind;
if (kind !== "note" && kind !== "rest" && kind !== "space") continue;
items.push(
pngItemFromNote({
kind,
pitch: slot.dataset.pitch ?? "",
quarters: Number(slot.dataset.quarters),
label: "",
}),
);
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.");
const layout = pngLayout(items, title);
const scale = 2;
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(layout.width * scale);
canvas.height = Math.ceil(layout.height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not start a canvas.");
ctx.scale(scale, scale);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, layout.width, layout.height);
ctx.fillStyle = "#6a4328";
ctx.font = "600 12px Georgia, serif";
ctx.fillText("Soprano in C · Baroque · 4/4", layout.pad, layout.pad + 14);
ctx.fillStyle = INK;
ctx.font = "600 22px Georgia, serif";
ctx.fillText(title, layout.pad, layout.pad + 40);
const glyphTop = layout.pad + 56;
let x = layout.pad;
items.forEach((item, index) => {
const slotWidth = layout.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.restore();
} else if (images[index]) {
ctx.drawImage(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.textAlign = "start";
x += slotWidth + layout.gap;
});
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new Error("Could not encode the PNG.");
return blob;
}
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1500);
}