MediaWiki:Gadget-ProfessionalTunebookPDF.js
Appearance
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (â-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (â-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(function ($, mw) {
"use strict";
// Load required OOUI libraries for dialog windows
mw.loader.using(["oojs-ui-core", "oojs-ui-widgets", "oojs-ui-windows"]).done(function () {
console.log("â
TunebookPDF Gadget loaded successfully.");
/********************************************************************
* 1) Dialog Definition
* This dialog allows the user to select which scores (rendered by abcjs)
* to include in the tunebook. If more than one score is detected, a textbox
* for a custom tunebook title and a checkbox to include an index page are shown.
********************************************************************/
function TuneBookPrintDialog(config) {
TuneBookPrintDialog.super.call(this, config);
}
OO.inheritClass(TuneBookPrintDialog, OO.ui.ProcessDialog);
TuneBookPrintDialog.static.name = 'tunebookPrintDialog';
TuneBookPrintDialog.static.title = 'Print Sheet Music';
TuneBookPrintDialog.static.actions = [
{ action: 'cancel', label: 'Cancel', flags: 'safe' },
{ action: 'accept', label: 'Generate PDF', flags: ['primary', 'progressive'] }
];
TuneBookPrintDialog.prototype.initialize = function () {
TuneBookPrintDialog.super.prototype.initialize.call(this);
this.content = new OO.ui.PanelLayout({ padded: true, expanded: false });
var $scores = $('.abcrendered');
console.log("DEBUG: Found " + $scores.length + " .abcrendered elements.");
var scoresCheckboxes = [];
if ($scores.length === 0) {
this.content.$element.append($('<p>').text('â ď¸ No score found on this page.'));
} else {
this.content.$element.append($('<p>').text('Select the scores to include in the tunebook.'));
$scores.each(function (index, element) {
var $svg = $(element).find('svg');
var scoreTitle = "Score " + (index + 1);
if ($svg.length > 0) {
var titleElement = $svg.find('title').first();
if (titleElement.length > 0) {
scoreTitle = titleElement.text().trim();
}
}
var checkbox = new OO.ui.CheckboxInputWidget({ selected: true });
console.log("DEBUG: Created checkbox for " + scoreTitle);
scoresCheckboxes.push({
checkbox: checkbox,
title: scoreTitle,
element: $(element)
});
var field = new OO.ui.FieldLayout(checkbox, {
label: scoreTitle,
align: 'inline'
});
this.content.$element.append(field.$element);
}.bind(this));
if ($scores.length > 1) {
this.customTitleInput = new OO.ui.TextInputWidget({
placeholder: 'Enter your custom Tunebook title...'
});
console.log("DEBUG: customTitleInput created.");
var titleField = new OO.ui.FieldLayout(this.customTitleInput, {
label: 'Custom Tunebook Title',
align: 'top'
});
this.content.$element.append(titleField.$element);
this.includeIndexCheckbox = new OO.ui.CheckboxInputWidget({ selected: false });
console.log("DEBUG: includeIndexCheckbox created, initial value: " + this.includeIndexCheckbox.isSelected());
var indexField = new OO.ui.FieldLayout(this.includeIndexCheckbox, {
label: 'Include Index Page',
align: 'top'
});
this.content.$element.append(indexField.$element);
}
}
this.scoresCheckboxes = scoresCheckboxes;
this.$body.append(this.content.$element);
};
TuneBookPrintDialog.prototype.onOpen = function () {
TuneBookPrintDialog.super.prototype.onOpen.call(this);
if (this.scoresCheckboxes.length === 0) {
this.$actionArea.find('button[data-action="accept"]').prop('disabled', true);
}
};
// Open a new window (placeholder) synchronously to avoid popup blockers.
TuneBookPrintDialog.prototype.getActionProcess = function (action) {
var dialog = this;
if (action === 'accept') {
return new OO.ui.Process(function () {
var selectedScores = dialog.scoresCheckboxes
.filter(function (item) { return item.checkbox.isSelected(); })
.map(function (item) { return item.element; });
console.log("DEBUG: Selected scores: " + selectedScores.length);
if (selectedScores.length === 0) {
dialog.close({ action: action });
return;
}
var customTunebookTitle = null;
if (dialog.customTitleInput) {
var rawTitle = dialog.customTitleInput.getValue().trim();
console.log("DEBUG: customTitleInput value: " + rawTitle);
if (rawTitle.length > 0) {
customTunebookTitle = rawTitle;
}
}
var includeIndex = false;
if (dialog.includeIndexCheckbox) {
includeIndex = dialog.includeIndexCheckbox.isSelected();
console.log("DEBUG: includeIndex: " + includeIndex);
}
var newWindow = window.open("about:blank", '_blank', 'width=900,height=1000,top=100,left=100');
if (!newWindow) {
console.error("Popup blocked!");
return;
}
newWindow.document.open();
newWindow.document.write(
'<!DOCTYPE html>' +
'<html lang="en">' +
'<head><meta charset="UTF-8"><title>Generating PDF</title></head>' +
'<body style="text-align: center; font-family: Arial, sans-serif; margin-top: 50px;">' +
'<h2>Generating PDF, please wait...</h2>' +
'</body></html>'
);
newWindow.document.close();
dialog.close({ action: action });
console.log("Starting PDF Tunebook generation...");
generateTunebookPDF(selectedScores, customTunebookTitle, includeIndex, newWindow);
});
} else if (action === 'cancel') {
return new OO.ui.Process(function () {
console.log("Closing dialog without printing.");
dialog.close({ action: action });
});
}
return TuneBookPrintDialog.super.prototype.getActionProcess.call(this, action);
};
/********************************************************************
* 2) PDF Generation Function
*
* Options:
* - Collapsed scores (without <svg>) are skipped.
* - If exactly one expanded score exists, no cover or title page is added.
* - If multiple expanded scores exist, a cover with a split title is generated.
* - The PDF is generated using JPEG images, with compression enabled.
* - qualityFactor is reduced to 3 to lower the resolution.
********************************************************************/
function generateTunebookPDF(selectedScores, customTitle, includeIndex, newWindow) {
$("#pdf-loading-overlay").remove();
$.getScript("https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js")
.done(function () {
console.log("jsPDF loaded successfully.");
// Create the overlay.
var $loadingOverlay = $('<div id="pdf-loading-overlay">Generating PDF, please wait...</div>').css({
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(255, 255, 255, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
zIndex: 10000
});
$('body').append($loadingOverlay);
// Create jsPDF instance with compression enabled.
const { jsPDF } = window.jspdf;
// Pass compress:true to reduce file size.
var pdf = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'a4', compress: true });
pdf.setFont("times", "normal");
const totalPageWidth = pdf.internal.pageSize.getWidth();
const totalPageHeight = pdf.internal.pageSize.getHeight();
const sideMargin = 20, topMargin = 20, bottomMargin = 20;
const safeMargin = 100; // Additional bottom space.
const pageWidth = totalPageWidth - (sideMargin * 2);
const pageHeight = (totalPageHeight - topMargin - bottomMargin) - safeMargin;
// qualityFactor reduced to 3.
const qualityFactor = 3;
const indexLeftMargin = sideMargin + 10;
const indexAvailableWidth = totalPageWidth - (indexLeftMargin * 2);
let basePageTitle = document.title.replace(" - Tunearch", "").trim() || "Tunebook";
let pdfFileName = basePageTitle + ".pdf";
let tunebookTitle = basePageTitle + "'s Tunebook";
if (customTitle) {
tunebookTitle = customTitle;
pdfFileName = customTitle.replace(/[<>:"\/\\|?*]+/g, '').replace(/\s+/g, '_') + ".pdf";
}
if (selectedScores.length === 1 && !customTitle) {
const singleSvg = selectedScores[0].find('svg').get(0);
if (singleSvg) {
const titleElement = $(singleSvg).find('title').first();
if (titleElement.length > 0) {
const tuneTitle = titleElement.text().trim()
.replace(/Sheet Music for\s*/i, '')
.replace(/"/g, '')
.replace(/[<>:"\/\\|?*]+/g, '')
.replace(/\s+/g, '_');
pdfFileName = tuneTitle + ".pdf";
tunebookTitle = tuneTitle;
}
}
}
console.log("Tunebook Title: " + tunebookTitle);
// Replace " - " with newline in the title.
const formattedTitle = tunebookTitle.replace(/\s*-\s*/g, "\n");
// Filter out collapsed scores.
const expandedScores = selectedScores.filter(function ($el) {
return $el.find('svg').length > 0;
});
if (expandedScores.length === 0) {
console.log("No expanded scores found.");
$loadingOverlay.remove();
newWindow.document.open();
newWindow.document.write(
'<!DOCTYPE html>' +
'<html lang="en">' +
'<head><meta charset="UTF-8"><title>No Expanded Scores</title></head>' +
'<body style="text-align: center; font-family: Arial, sans-serif; margin-top: 50px;">' +
'<h2>Expand at least one score to generate the PDF.</h2>' +
'<p>Please close this window and try again.</p>' +
'<button onclick="window.close();">Close</button>' +
'</body></html>'
);
newWindow.document.close();
newWindow.focus();
return;
}
// Determine if cover is needed: if more than one expanded score, add cover.
const multipleScores = (expandedScores.length > 1);
if (multipleScores) {
pdf.setFontSize(26);
// Use splitTextToSize to wrap the title.
const titleLines = pdf.splitTextToSize(formattedTitle, totalPageWidth - 40);
pdf.text(titleLines, totalPageWidth / 2, 250, { align: 'center' });
// Add logo and additional cover text.
const logoImg = new Image();
logoImg.src = 'https://ttadev.org/w/images/tmp/UniversalClef.jpg';
pdf.addImage(logoImg, 'JPEG', 210, totalPageHeight - 300, 140, 140);
pdf.setFontSize(14);
pdf.text("The Traditional Tune Archive", totalPageWidth / 2, totalPageHeight - 130, { align: 'center' });
pdf.setFontSize(12);
pdf.text("The Semantic Index of North American,", totalPageWidth / 2, totalPageHeight - 110, { align: 'center' });
pdf.text("British and Irish traditional instrumental music with annotation.", totalPageWidth / 2, totalPageHeight - 90, { align: 'center' });
console.log("Cover drawn on first page (cover is page 1, not numbered).");
}
// (If only one expanded score, no cover and no title page.)
// Helper function to process an SVG into one or more JPEG images.
function processSVG(svgElement) {
return new Promise(function (resolve, reject) {
const svgData = new XMLSerializer().serializeToString(svgElement);
const img = new Image();
img.onload = function () {
const originalWidth = img.width;
const originalHeight = img.height;
const scale = pageWidth / originalWidth;
const scaledHeight = originalHeight * scale;
// Create a canvas with a white background
function drawCanvas(width, height) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
// Fill the canvas with white
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
return canvas;
}
if (scaledHeight <= pageHeight) {
// Single page
const canvas = drawCanvas(pageWidth * qualityFactor, scaledHeight * qualityFactor);
const ctx = canvas.getContext("2d");
ctx.drawImage(
img,
0, 0, originalWidth, originalHeight,
0, 0, canvas.width, canvas.height
);
// Convert to JPEG (quality 0.8 for example)
resolve([{
imgData: canvas.toDataURL("image/jpeg", 0.8),
imgWidth: pageWidth,
imgHeight: scaledHeight
}]);
} else {
// Multi-page scenario
const numPages = Math.ceil(scaledHeight / pageHeight);
const pageImages = [];
// Draw the entire image once on a large canvas
const fullCanvas = drawCanvas(pageWidth * qualityFactor, scaledHeight * qualityFactor);
const fullCtx = fullCanvas.getContext("2d");
fullCtx.drawImage(
img,
0, 0, originalWidth, originalHeight,
0, 0, fullCanvas.width, fullCanvas.height
);
for (let i = 0; i < numPages; i++) {
const segmentHeight = (i === numPages - 1)
? (scaledHeight - pageHeight * i)
: pageHeight;
const segmentCanvas = drawCanvas(pageWidth * qualityFactor, segmentHeight * qualityFactor);
const segmentCtx = segmentCanvas.getContext("2d");
segmentCtx.drawImage(
fullCanvas,
0, (pageHeight * i) * qualityFactor,
pageWidth * qualityFactor, segmentHeight * qualityFactor,
0, 0,
segmentCanvas.width, segmentCanvas.height
);
pageImages.push({
imgData: segmentCanvas.toDataURL("image/jpeg", 0.8),
imgWidth: pageWidth,
imgHeight: segmentHeight
});
}
resolve(pageImages);
}
};
img.onerror = function () {
reject(new Error("Error loading SVG image"));
};
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
});
}
// Process each expanded score.
const scorePromises = expandedScores.map(function ($el, idx) {
const svg = $el.find('svg').get(0);
let scoreTitle = "Score " + (idx + 1);
const $svg = $el.find('svg');
if ($svg.length > 0) {
const titleEl = $svg.find('title').first();
if (titleEl.length > 0) {
scoreTitle = titleEl.text().trim();
}
}
console.log("Processing " + scoreTitle);
return processSVG(svg).then(function (pages) {
console.log(scoreTitle + " produced " + pages.length + " page(s).");
return { title: scoreTitle, pages: pages };
}).catch(function (err) {
console.error(err);
return { title: scoreTitle, pages: [] };
});
});
Promise.all(scorePromises).then(function (scoreDataArray) {
// If multiple scores and index option selected, add an index page.
if (multipleScores && includeIndex && scoreDataArray.length > 1) {
pdf.addPage();
pdf.setFontSize(20);
pdf.text("Index", totalPageWidth / 2, 50, { align: 'center' });
pdf.setFontSize(14);
let yPos = 80;
let pageCounter = 1;
const indexData = [];
scoreDataArray.forEach(function (scoreObj) {
indexData.push({ title: scoreObj.title, startPage: pageCounter });
pageCounter += scoreObj.pages.length;
});
indexData.forEach(function (item) {
let cleanTitle = item.title.replace(/Sheet Music for\s*/i, '').replace(/"/g, '');
const pageText = String(item.startPage);
const titleWidth = pdf.getTextDimensions(cleanTitle).w;
const pageWidthText = pdf.getTextDimensions(pageText).w;
const available = indexAvailableWidth - titleWidth - pageWidthText - 20;
const dotWidth = pdf.getTextDimensions(".").w;
const numDots = Math.floor(available / dotWidth);
const dots = new Array(numDots + 1).join(".");
pdf.text(`${cleanTitle} ${dots} ${pageText}`, indexLeftMargin, yPos);
yPos += 20;
});
console.log("Index page added.");
}
let printedCounter = 1;
// Generate the score pages.
scoreDataArray.forEach(function (scoreObj) {
scoreObj.pages.forEach(function (pageObj) {
// If multiple scores, add a new page for each score page.
// If only one score is expanded, do not add an extra cover/page.
if (multipleScores || printedCounter > 1) {
pdf.addPage();
}
pdf.addImage(pageObj.imgData, 'JPEG', sideMargin + 10, topMargin, pageObj.imgWidth, pageObj.imgHeight);
pdf.setFontSize(10);
pdf.text("øCopyLeft - The Traditional Tune Archive (www.tunearch.org)",
totalPageWidth / 2,
totalPageHeight - 20,
{ align: 'center' }
);
pdf.text("page " + printedCounter,
totalPageWidth - 20,
totalPageHeight - 20,
{ align: 'right' }
);
console.log("Added score page, printed number: " + printedCounter);
printedCounter++;
});
});
const pdfBlob = pdf.output('blob');
const pdfUrl = URL.createObjectURL(pdfBlob);
console.log("PDF generation complete.");
setTimeout(function () {
$loadingOverlay.remove();
newWindow.document.open();
newWindow.document.write(
'<!DOCTYPE html>' +
'<html lang="en">' +
'<head><meta charset="UTF-8"><title>' + tunebookTitle + '</title></head>' +
'<body style="text-align: center; font-family: Times New Roman, serif;">' +
(multipleScores ? `<h2>${tunebookTitle}</h2>` : '') +
'<embed width="90%" height="600px" src="' + pdfUrl + '" type="application/pdf"></embed>' +
'<br><br>' +
'<a href="' + pdfUrl + '" download="' + pdfFileName + '" style="padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px;">Download PDF</a>' +
'</body></html>'
);
newWindow.document.close();
newWindow.focus();
console.log("PDF generated and new window updated after delay.");
}, 2000);
}).catch(function (err) {
console.error("Error processing scores:", err);
$loadingOverlay.remove();
});
})
.fail(function () {
console.error("Error loading jsPDF library!");
});
}
/********************************************************************
* 3) Toolbox Integration
********************************************************************/
function openTuneBookPrintDialog() {
const windowManager = new OO.ui.WindowManager();
$(document.body).append(windowManager.$element);
const dialog = new TuneBookPrintDialog();
windowManager.addWindows([dialog]);
windowManager.openWindow(dialog);
}
function addTunebookPrintLink() {
var $actions = $('#p-cactions ul');
if ($actions.length === 0) { return; }
if ($('#tunebook-print').length === 0) {
var $newLink = $('<li><a href="#" id="tunebook-print">đ Print Sheet Music</a></li>');
$actions.append($newLink);
console.log("DEBUG: 'Print Tunebook' link added to the Actions menu.");
}
}
$(document).on('click', '#tunebook-print', function (e) {
e.preventDefault();
openTuneBookPrintDialog();
});
setTimeout(function () {
$(addTunebookPrintLink);
}, 0);
});
})(jQuery, mediaWiki);