UNPKG

abcjs

Version:

Renderer for abc music notation

443 lines (415 loc) 17.3 kB
// wrap_lines.js: does line wrap on an already parsed tune. function wrapLines(tune, lineBreaks, barNumbers) { if (!lineBreaks || tune.lines.length === 0) return; // tune.lines contains nested arrays: there is an array of lines (that's the part this function rewrites), // there is an array of staffs per line (for instance, piano will have 2, orchestra will have many) // there is an array of voices per staff (for instance, 4-part harmony might have bass and tenor on a single staff) var lines = tune.deline({lineBreaks: false}); var linesBreakElements = findLineBreaks(lines, lineBreaks); //console.log(JSON.stringify(linesBreakElements)) tune.lines = addLineBreaks(lines, linesBreakElements, barNumbers); tune.lineBreaks = linesBreakElements; } function addLineBreaks(lines, linesBreakElements, barNumbers) { // linesBreakElements is an array of all of the elements that break for a new line // The objects in the array look like: // {"ogLine":0,"line":0,"staff":0,"voice":0,"start":0, "end":21} // ogLine is the original line that it came from, // line is the target line. // then copy all the elements from start to end for the staff and voice specified. // If the item doesn't contain "staff" then it is a non music line and should just be copied. var outputLines = []; var lastKeySig = []; // This is per staff - if the key changed then this will be populated. var lastStem = []; var currentBarNumber = 1; for (var i = 0; i < linesBreakElements.length; i++) { var action = linesBreakElements[i]; if (lines[action.ogLine].staff) { var inputStaff = lines[action.ogLine].staff[action.staff]; if (!outputLines[action.line]) { outputLines[action.line] = {staff: []} } if (!outputLines[action.line].staff[action.staff]) { outputLines[action.line].staff[action.staff] = {voices: []}; if (barNumbers !== undefined && action.staff === 0 && action.line > 0) { outputLines[action.line].staff[action.staff].barNumber = currentBarNumber; } var keys = Object.keys(inputStaff) for (var k = 0; k < keys.length; k++) { var skip = keys[k] === "voices"; if (keys[k] === "meter" && action.line !== 0) skip = true; if (!skip) outputLines[action.line].staff[action.staff][keys[k]] = inputStaff[keys[k]]; } if (lastKeySig[action.staff]) outputLines[action.line].staff[action.staff].key = lastKeySig[action.staff]; } if (!outputLines[action.line].staff[action.staff].voices[action.voice]) { outputLines[action.line].staff[action.staff].voices[action.voice] = []; } outputLines[action.line].staff[action.staff].voices[action.voice] = lines[action.ogLine].staff[action.staff].voices[action.voice].slice(action.start, action.end+1); if (lastStem[action.staff*10+action.voice]) outputLines[action.line].staff[action.staff].voices[action.voice].unshift({el_type: "stem", direction: lastStem[action.staff*10+action.voice].direction}) var currVoice = outputLines[action.line].staff[action.staff].voices[action.voice]; for (var kk = currVoice.length-1; kk >= 0; kk--) { if (currVoice[kk].el_type === "key") { lastKeySig[action.staff] = { root: currVoice[kk].root, acc: currVoice[kk].acc, mode: currVoice[kk].mode, accidentals: currVoice[kk].accidentals.filter(function (acc) { return acc.acc !== 'natural' }) }; break; } } for (kk = currVoice.length-1; kk >= 0; kk--) { if (currVoice[kk].el_type === "stem") { lastStem[action.staff*10+action.voice] = { direction: currVoice[kk].direction, }; break; } } if (barNumbers !== undefined && action.staff === 0 && action.voice === 0) { for (kk = 0; kk < currVoice.length; kk++) { if (currVoice[kk].el_type === 'bar') { currentBarNumber++ if (kk === currVoice.length-1) delete currVoice[kk].barNumber else currVoice[kk].barNumber = currentBarNumber } } } } else { outputLines[action.line] = lines[action.ogLine]; } } // There could be some missing info - if the tune passed in was incomplete or had different lengths for different voices or was missing a voice altogether - just fill in the gaps. for (var ii = 0; ii < outputLines.length; ii++) { if (outputLines[ii].staff) { outputLines[ii].staff = outputLines[ii].staff.filter(function (el) { return el != null; }); } } return outputLines; } function findLineBreaks(lines, lineBreakArray) { // lineBreakArray is an array of all of the sections of the tune - often there will just be one // section unless there is a subtitle or other non-music lines. Each of the elements of // Each element of lineBreakArray is an array of the zero-based last measure of the line. var lineBreakIndexes = []; var lbai = 0; var lineCounter = 0; var outputLine = 0; for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (line.staff) { var lineStart = lineCounter; var lineBreaks = lineBreakArray[lbai]; lbai++; for (var j = 0; j < line.staff.length; j++) { var staff = line.staff[j]; for (var k = 0; k < staff.voices.length; k++) { outputLine = lineStart; var measureNumber = 0; var lbi = 0; var voice = staff.voices[k]; var start = 0; for (var e = 0; e < voice.length; e++) { var el = voice[e]; if (el.el_type === 'bar') { if (lineBreaks[lbi] === measureNumber) { lineBreakIndexes.push({ ogLine: i, line: outputLine, staff: j, voice: k, start: start, end: e}) start = e + 1; outputLine++; lineCounter = Math.max(lineCounter, outputLine) lbi++; } measureNumber++; } } lineBreakIndexes.push({ ogLine: i, line: outputLine, staff: j, voice: k, start: start, end: voice.length}) outputLine++; lineCounter = Math.max(lineCounter, outputLine) } } } else { lineBreakIndexes.push({ ogLine: i, line: outputLine }) outputLine++; lineCounter = Math.max(lineCounter, outputLine) } } return lineBreakIndexes; } function freeFormLineBreaks(widths, lineBreakPoint) { var lineBreaks = []; var totals = []; var totalThisLine = 0; // run through each measure and see if the accumulation is less than the ideal. // if it passes the ideal, then see whether the last or this one is closer to the ideal. for (var i = 0; i < widths.length; i++) { var width = widths[i]; var attemptedWidth = totalThisLine + width; if (attemptedWidth < lineBreakPoint) totalThisLine = attemptedWidth; else { // This just passed the ideal, so see whether the previous or the current number of measures is closer. var oldDistance = lineBreakPoint - totalThisLine; var newDistance = attemptedWidth - lineBreakPoint; if (oldDistance < newDistance && totalThisLine > 0) { lineBreaks.push(i - 1); totals.push(Math.round(totalThisLine - width)); totalThisLine = width; } else { if (i < widths.length-1) { lineBreaks.push(i); totals.push(Math.round(totalThisLine)); totalThisLine = 0; } } } } totals.push(Math.round(totalThisLine)); return { lineBreaks: lineBreaks, totals: totals }; } function clone(arr) { var newArr = []; for (var i = 0; i < arr.length; i++) newArr.push(arr[i]); return newArr; } function oneTry(measureWidths, idealWidths, accumulator, lineAccumulator, lineWidths, lastVariance, highestVariance, currLine, lineBreaks, startIndex, otherTries) { for (var i = startIndex; i < measureWidths.length; i++) { var measureWidth = measureWidths[i]; accumulator += measureWidth; lineAccumulator += measureWidth; var thisVariance = Math.abs(accumulator - idealWidths[currLine]); var varianceIsClose = Math.abs(thisVariance - lastVariance) < idealWidths[0] / 10; // see if the difference is less than 10%, if so, run the test both ways. if (varianceIsClose) { if (thisVariance < lastVariance) { // Also attempt one less measure on the current line - sometimes that works out better. var newWidths = clone(lineWidths); var newBreaks = clone(lineBreaks); newBreaks.push(i-1); newWidths.push(lineAccumulator - measureWidth); otherTries.push({ accumulator: accumulator, lineAccumulator: measureWidth, lineWidths: newWidths, lastVariance: Math.abs(accumulator - idealWidths[currLine+1]), highestVariance: Math.max(highestVariance, lastVariance), currLine: currLine+1, lineBreaks: newBreaks, startIndex: i+1}); } else if (thisVariance > lastVariance && i < measureWidths.length-1) { // Also attempt one extra measure on this line. newWidths = clone(lineWidths); newBreaks = clone(lineBreaks); // newBreaks[newBreaks.length-1] = i; // newWidths[newWidths.length-1] = lineAccumulator; otherTries.push({ accumulator: accumulator, lineAccumulator: lineAccumulator, lineWidths: newWidths, lastVariance: thisVariance, highestVariance: Math.max(highestVariance, thisVariance), currLine: currLine, lineBreaks: newBreaks, startIndex: i+1}); } } if (thisVariance > lastVariance) { lineBreaks.push(i - 1); currLine++; highestVariance = Math.max(highestVariance, lastVariance); lastVariance = Math.abs(accumulator - idealWidths[currLine]); lineWidths.push(lineAccumulator - measureWidth); lineAccumulator = measureWidth; } else { lastVariance = thisVariance; } } lineWidths.push(lineAccumulator); } function optimizeLineWidths(widths, lineBreakPoint, lineBreaks, explanation) { // figure out how many lines var numLines = Math.ceil(widths.total / lineBreakPoint); // + 1 TODO-PER: this used to be plus one - not sure why // get the ideal width for a line (cumulative width / num lines) - approx the same as lineBreakPoint except for rounding var idealWidth = Math.floor(widths.total / numLines); // get each ideal line width (1*ideal, 2*ideal, 3*ideal, etc) var idealWidths = []; for (var i = 0; i < numLines; i++) idealWidths.push(idealWidth*(i+1)); // from first measure, step through accum. Widths until the abs of the ideal is greater than the last one. // This can sometimes look funny in edge cases, so when the length is within 10%, try one more or one less to see which is better. // This is better than trying all the possibilities because that would get to be a huge number for even a medium size piece. // This method seems to never generate more than about 16 tries and it is usually 4 or less. var otherTries = []; otherTries.push({ accumulator: 0, lineAccumulator: 0, lineWidths: [], lastVariance: 999999, highestVariance: 0, currLine: 0, lineBreaks: [], // These are the zero-based last measure on each line startIndex: 0}); var index = 0; while (index < otherTries.length) { oneTry(widths.measureWidths, idealWidths, otherTries[index].accumulator, otherTries[index].lineAccumulator, otherTries[index].lineWidths, otherTries[index].lastVariance, otherTries[index].highestVariance, otherTries[index].currLine, otherTries[index].lineBreaks, otherTries[index].startIndex, otherTries); index++; } for (i = 0; i < otherTries.length; i++) { var otherTry = otherTries[i]; otherTry.variances = []; otherTry.aveVariance = 0; for (var j = 0; j < otherTry.lineWidths.length; j++) { var lineWidth = otherTry.lineWidths[j]; otherTry.variances.push(lineWidth - idealWidths[0]); otherTry.aveVariance += Math.abs(lineWidth - idealWidths[0]); } otherTry.aveVariance = otherTry.aveVariance / otherTry.lineWidths.length; explanation.attempts.push({ type: "optimizeLineWidths", lineBreaks: otherTry.lineBreaks, variances: otherTry.variances, aveVariance: otherTry.aveVariance, widths: widths.measureWidths }); } var smallest = 9999999; var smallestIndex = -1; for (i = 0; i < otherTries.length; i++) { otherTry = otherTries[i]; if (otherTry.aveVariance < smallest) { smallest = otherTry.aveVariance; smallestIndex = i; } } return { failed: false, lineBreaks: otherTries[smallestIndex].lineBreaks, variance: otherTries[smallestIndex].highestVariance }; } function fixedMeasureLineBreaks(widths, lineBreakPoint, preferredMeasuresPerLine) { var lineBreaks = []; var totals = []; var thisWidth = 0; var failed = false; for (var i = 0; i < widths.length; i++) { thisWidth += widths[i]; if (thisWidth > lineBreakPoint) { failed = true; } if (i % preferredMeasuresPerLine === (preferredMeasuresPerLine-1)) { if (i !== widths.length-1) // Don't bother putting a line break for the last line - it's already a break. lineBreaks.push(i); totals.push(Math.round(thisWidth)); thisWidth = 0; } } return { failed: failed, totals: totals, lineBreaks: lineBreaks }; } function getRevisedTuneParams(lineBreaks, staffWidth, params) { var revisedParams = { lineBreaks: lineBreaks, staffwidth: staffWidth }; for (var key in params) { if (params.hasOwnProperty(key) && key !== 'wrap' && key !== 'staffwidth') { revisedParams[key] = params[key]; } } return { revisedParams: revisedParams }; } function calcLineWraps(tune, widths, params) { // For calculating how much can go on the line, it depends on the width of the line. It is a convenience to just divide it here // by the minimum spacing instead of multiplying the min spacing later. // The scaling works differently: this is done by changing the scaling of the outer SVG, so the scaling needs to be compensated // for here, because the actual width will be different from the calculated numbers. // If the desired width is less than the margin, just punt and return the original tune //console.log(widths) if (widths.length === 0 || params.staffwidth < widths[0].left) { return { reParse: false, explanation: "Staff width is narrower than the margin", revisedParams: params }; } var scale = params.scale ? Math.max(params.scale, 0.1) : 1; var minSpacing = params.wrap.minSpacing ? Math.max(parseFloat(params.wrap.minSpacing), 1) : 1; var minSpacingLimit = params.wrap.minSpacingLimit ? Math.max(parseFloat(params.wrap.minSpacingLimit), 1) : minSpacing - 0.1; var maxSpacing = params.wrap.maxSpacing ? Math.max(parseFloat(params.wrap.maxSpacing), 1) : undefined; if (params.wrap.lastLineLimit && !maxSpacing) maxSpacing = Math.max(parseFloat(params.wrap.lastLineLimit), 1); // var targetHeight = params.wrap.targetHeight ? Math.max(parseInt(params.wrap.targetHeight, 10), 100) : undefined; var preferredMeasuresPerLine = params.wrap.preferredMeasuresPerLine ? Math.max(parseInt(params.wrap.preferredMeasuresPerLine, 10), 0) : undefined; var accumulatedLineBreaks = []; var explanations = []; for (var s = 0; s < widths.length; s++) { var section = widths[s]; var usableWidth = params.staffwidth - section.left; var lineBreakPoint = usableWidth / minSpacing / scale; var minLineSize = usableWidth / maxSpacing / scale; var allowableVariance = usableWidth / minSpacingLimit / scale; var explanation = { widths: section, lineBreakPoint: lineBreakPoint, minLineSize: minLineSize, attempts: [], staffWidth: params.staffwidth, minWidth: Math.round(allowableVariance) }; // If there is a preferred number of measures per line, test that first. If none of the lines is too long, then we're finished. var lineBreaks = null; if (preferredMeasuresPerLine) { var f = fixedMeasureLineBreaks(section.measureWidths, lineBreakPoint, preferredMeasuresPerLine); explanation.attempts.push({ type: "Fixed Measures Per Line", preferredMeasuresPerLine: preferredMeasuresPerLine, lineBreaks: f.lineBreaks, failed: f.failed, totals: f.totals }); if (!f.failed) lineBreaks = f.lineBreaks; } // If we don't have lineBreaks yet, use the free form method of line breaks. // This will be called either if Preferred Measures is not used, or if the music is just weird - like a single measure is way too crowded. if (!lineBreaks) { var ff = freeFormLineBreaks(section.measureWidths, lineBreakPoint); explanation.attempts.push({type: "Free Form", lineBreaks: ff.lineBreaks, totals: ff.totals}); lineBreaks = ff.lineBreaks; // We now have an acceptable number of lines, but the measures may not be optimally distributed. See if there is a better distribution. if (lineBreaks.length > 0 && section.measureWidths.length < 25) { // Only do this if everything doesn't fit on one line. // This is an intensive operation and it is optional so just do it for shorter music. ff = optimizeLineWidths(section, lineBreakPoint, lineBreaks, explanation); explanation.attempts.push({ type: "Optimize", failed: ff.failed, reason: ff.reason, lineBreaks: ff.lineBreaks, totals: ff.totals }); if (!ff.failed) lineBreaks = ff.lineBreaks; } } accumulatedLineBreaks.push(lineBreaks); explanations.push(explanation); } // If the vertical space exceeds targetHeight, remove a line and try again. If that is too crowded, then don't use it. var staffWidth = params.staffwidth; var ret = getRevisedTuneParams(accumulatedLineBreaks, staffWidth, params); ret.explanation = explanations; ret.reParse = true; return ret; } module.exports = { wrapLines: wrapLines, calcLineWraps: calcLineWraps };