diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js
index ba40c3cc..44b612b8 100644
--- a/desktop-app/prepare.js
+++ b/desktop-app/prepare.js
@@ -59,23 +59,24 @@ console.log("✓ Copied assets/ → resources/assets/ (excluding GIF demos)");
/**
* Validates the cryptographic integrity of a file against an expected SHA-384 hash.
*/
-function verifyIntegrity(filePath, expectedSha384) {
+function verifyIntegrity(filePath, expectedHash) {
return new Promise((resolve, reject) => {
- if (!expectedSha384) {
+ if (!expectedHash) {
resolve(true); // Skip validation if no hash is provided (e.g., relative fonts)
return;
}
- const hash = crypto.createHash("sha384");
+ const algo = expectedHash.startsWith("sha512-") ? "sha512" : "sha384";
+ const hash = crypto.createHash(algo);
const stream = fs.createReadStream(filePath);
stream.on("data", data => hash.update(data));
stream.on("end", () => {
- const calculated = "sha384-" + hash.digest("base64");
- if (calculated === expectedSha384) {
+ const calculated = algo + "-" + hash.digest("base64");
+ if (calculated === expectedHash) {
resolve(true);
} else {
- reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedSha384}\nCalculated: ${calculated}`));
+ reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedHash}\nCalculated: ${calculated}`));
}
});
stream.on("error", reject);
@@ -187,6 +188,19 @@ async function prepareOfflineDependencies() {
downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2", path.join(fontDir, "bootstrap-icons.woff2"), null));
downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff", path.join(fontDir, "bootstrap-icons.woff"), null));
+ // Dynamic / Lazy-loaded dependencies to download manually for offline capability
+ const dynamicLibs = [
+ {
+ url: "https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js",
+ dest: path.join(LIBS_DIR, "abcjs-basic-min.js"),
+ hash: "sha512-QJ21PAOSw5KSiQ12gnP74qwLRAEn9GZtrFI0yY1akCLLpcEaC7xwZ7BiONZ/7pyrfUADyh7sHnI3SYHifO+tmg=="
+ }
+ ];
+
+ for (const lib of dynamicLibs) {
+ downloads.push(downloadFile(lib.url, lib.dest, lib.hash));
+ }
+
// Wait for all downloads and cryptographic validations to finish
try {
await Promise.all(downloads);
diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js
index 97c9a9a2..ba54be73 100644
--- a/desktop-app/resources/js/preview-worker.js
+++ b/desktop-app/resources/js/preview-worker.js
@@ -3,6 +3,7 @@
let librariesLoaded = false;
let markedConfigured = false;
let mermaidIdCounter = 0;
+let abcIdCounter = 0;
const markedOptions = {
gfm: true,
@@ -312,6 +313,11 @@ function configureMarked() {
return `
`;
}
+ if (language === "abc") {
+ const uniqueId = `abc-notation-worker-${abcIdCounter++}`;
+ return ``;
+ }
+
const validLanguage = hljs && hljs.getLanguage(language) ? language : "plaintext";
const highlightedCode = hljs
? hljs.highlight(code, { language: validLanguage }).value
@@ -481,6 +487,7 @@ self.onmessage = function(event) {
const options = data.options || {};
ensureLibraries(options.libraryUrls || {});
mermaidIdCounter = 0;
+ abcIdCounter = 0;
const result = renderSegmentedMarkdown(data.markdown || "", options);
self.postMessage({
type: "render-result",
diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js
index 9f457d2a..a0652a7a 100644
--- a/desktop-app/resources/js/script.js
+++ b/desktop-app/resources/js/script.js
@@ -33,9 +33,15 @@ document.addEventListener("DOMContentLoaded", function () {
html2canvas: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js',
joypixels: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js',
- joypixels_css: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css'
+ joypixels_css: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css',
+ abcjs: 'https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js'
};
+ // Resolve local paths for desktop (Neutralinojs) offline support
+ if (typeof Neutralino !== 'undefined') {
+ CDN.abcjs = '/libs/abcjs-basic-min.js';
+ }
+
let markdownRenderTimeout = null;
let pendingPreviewRenderCancel = null;
let previewRenderGeneration = 0;
@@ -52,7 +58,7 @@ document.addEventListener("DOMContentLoaded", function () {
const PREVIEW_BLOCK_REUSE_LIMIT = 12000;
const PREVIEW_SANITIZE_OPTIONS = {
ADD_TAGS: ['mjx-container', 'input'],
- ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'],
+ ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code', 'role', 'aria-labelledby', 'aria-describedby'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
};
const RENDER_DELAY = 100;
@@ -924,6 +930,15 @@ document.addEventListener("DOMContentLoaded", function () {
.replace(/>/g, ">");
return ``;
}
+
+ if (language === 'abc') {
+ const uniqueId = 'abc-notation-' + Math.random().toString(36).substr(2, 9);
+ const escapedCode = code
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+ return ``;
+ }
const validLanguage = hljs.getLanguage(language) ? language : "plaintext";
const highlightedCode = hljs.highlight(code, {
@@ -2094,6 +2109,23 @@ document.addEventListener("DOMContentLoaded", function () {
queryPreviewRoots(roots, '.mermaid-container.is-loading').forEach(function(container) {
container.classList.remove('is-loading');
});
+ queryPreviewRoots(roots, '.abc-container.is-loading').forEach(function(container) {
+ container.classList.remove('is-loading');
+ });
+ }
+
+ function parseAbcHeaders(abcString) {
+ const titleMatch = /^T:\s*(.*)$/m.exec(abcString);
+ const composerMatch = /^C:\s*(.*)$/m.exec(abcString);
+ const keyMatch = /^K:\s*(.*)$/m.exec(abcString);
+ const meterMatch = /^M:\s*(.*)$/m.exec(abcString);
+
+ return {
+ title: titleMatch ? titleMatch[1].trim() : "Music notation block",
+ composer: composerMatch ? composerMatch[1].trim() : "Traditional",
+ key: keyMatch ? keyMatch[1].trim() : "C",
+ meter: meterMatch ? meterMatch[1].trim() : "4/4"
+ };
}
function postProcessPreview(rawVal, context, patchResult) {
@@ -2143,6 +2175,131 @@ document.addEventListener("DOMContentLoaded", function () {
console.warn("Mermaid rendering failed:", e);
}
+ try {
+ const abcNodes = queryPreviewRoots(roots, '.abc-notation');
+ if (abcNodes.length > 0) {
+ const renderAbcNodes = function() {
+ if (context.renderId !== previewRenderGeneration) return;
+
+ const observer = new IntersectionObserver((entries, obs) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ const node = entry.target;
+ obs.unobserve(node);
+
+ setTimeout(() => {
+ if (context.renderId !== previewRenderGeneration) return;
+ const originalCode = node.getAttribute('data-original-code');
+ if (!originalCode) return;
+ const decodedCode = decodeURIComponent(originalCode);
+
+ const container = node.closest('.abc-container');
+ try {
+ node.innerHTML = '';
+ ABCJS.renderAbc(node.id, decodedCode, {
+ responsive: "resize",
+ add_classes: true
+ });
+
+ node.innerHTML = DOMPurify.sanitize(node.innerHTML, PREVIEW_SANITIZE_OPTIONS);
+
+ const headers = parseAbcHeaders(decodedCode);
+ const svgElement = node.querySelector('svg');
+ if (svgElement) {
+ svgElement.setAttribute('role', 'img');
+ const titleId = 'abc-title-' + node.id;
+ const descId = 'abc-desc-' + node.id;
+ svgElement.setAttribute('aria-labelledby', titleId + ' ' + descId);
+ svgElement.setAttribute('aria-describedby', 'abc-source-' + node.id);
+
+ const svgTitle = document.createElementNS('http://www.w3.org/2000/svg', 'title');
+ svgTitle.id = titleId;
+ svgTitle.textContent = `Sheet music for: ${headers.title}`;
+
+ const svgDesc = document.createElementNS('http://www.w3.org/2000/svg', 'desc');
+ svgDesc.id = descId;
+ svgDesc.textContent = `Score in ${headers.key}, ${headers.meter} meter, composed by ${headers.composer}.`;
+
+ svgElement.insertBefore(svgDesc, svgElement.firstChild);
+ svgElement.insertBefore(svgTitle, svgElement.firstChild);
+ }
+
+ if (container) {
+ container.classList.remove('is-loading');
+
+ if (!container.querySelector('.abc-toolbar')) {
+ const toolbar = document.createElement('div');
+ toolbar.className = 'abc-toolbar';
+ toolbar.style.cssText = 'display: flex; justify-content: flex-end; width: 100%; margin-bottom: 0.5em;';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'tool-button abc-toggle-btn';
+ btn.setAttribute('aria-pressed', 'false');
+ btn.style.cssText = 'padding: 2px 8px; font-size: 11px;';
+ btn.innerHTML = 'View Code';
+ toolbar.appendChild(btn);
+
+ const rawPre = document.createElement('pre');
+ rawPre.className = 'abc-raw-code';
+ rawPre.style.cssText = 'display: none; width: 100%; margin: 0; padding: 1em; background: var(--editor-bg); border-radius: 4px; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; color: var(--text-color);';
+ rawPre.textContent = decodedCode;
+
+ const srOnlyDiv = document.createElement('div');
+ srOnlyDiv.className = 'abc-sr-only';
+ srOnlyDiv.id = 'abc-source-' + node.id;
+ srOnlyDiv.textContent = decodedCode;
+
+ container.insertBefore(toolbar, node);
+ container.appendChild(rawPre);
+ container.appendChild(srOnlyDiv);
+
+ btn.addEventListener('click', function() {
+ const isPressed = btn.getAttribute('aria-pressed') === 'true';
+ btn.setAttribute('aria-pressed', !isPressed);
+ if (!isPressed) {
+ node.style.display = 'none';
+ rawPre.style.display = 'block';
+ btn.innerHTML = 'View Score';
+ } else {
+ node.style.display = 'block';
+ rawPre.style.display = 'none';
+ btn.innerHTML = 'View Code';
+ }
+ });
+ }
+ }
+ } catch (err) {
+ console.error("ABCJS rendering failed:", err);
+ if (container) container.classList.remove('is-loading');
+ }
+ }, 0);
+ }
+ });
+ }, { rootMargin: '150px 0px' });
+
+ abcNodes.forEach(node => observer.observe(node));
+ };
+
+ if (typeof ABCJS === 'undefined') {
+ loadScript(CDN.abcjs).then(function() {
+ if (context.renderId !== previewRenderGeneration) return;
+ renderAbcNodes();
+ }).catch(function(e) {
+ console.warn('Failed to load abcjs:', e);
+ abcNodes.forEach(function(node) {
+ const container = node.closest('.abc-container');
+ if (container) container.classList.remove('is-loading');
+ });
+ });
+ } else {
+ renderAbcNodes();
+ }
+ }
+ } catch (e) {
+ console.warn("ABC notation processing failed:", e);
+ }
+
const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || '');
if (hasMath) {
const typesetTargets = roots.filter(function(root) {
@@ -6739,6 +6896,7 @@ document.addEventListener("DOMContentLoaded", function () {
+