satie
Version:
A sheet music renderer for the web
235 lines (204 loc) • 9.24 kB
text/typescript
/**
* This file is part of Satie music engraver <https://github.com/jnetterf/satie>.
* Copyright (C) Joshua Netterfield <joshua.ca> 2015 - present.
*
* Satie is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Satie is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Satie. If not, see <http://www.gnu.org/licenses/>.
*/
import {Print} from "musicxml-interfaces";
import {map, reduce, find, last} from "lodash";
import * as invariant from "invariant";
import {IMeasure, getMeasureSegments, Type, reduceToShortestInSegments} from "./document";
import {ILayoutOptions} from "./private_layoutOptions";
import {calculateLineBounds} from "./private_lineBounds";
import {IMeasureLayout} from "./private_measureLayout";
import {layoutLine} from "./engine_processors_line";
const SQUISHINESS = 0.8;
export interface ILinePlacementHint {
widthByShortest: {[key: number]: number};
shortestCount: number;
attributesWidthStart: number;
attributesWidthEnd: number;
}
interface IReduceOptsMemo {
options: ILayoutOptions;
opts: ILayoutOptions[];
remainingWidth: number;
startingWidth: number;
thisPrint: Print;
widthAllocatedForEnd: number;
widthAllocatedForStart: number;
shortest: number;
}
function findPrint(options: ILayoutOptions, measure: IMeasure): Print {
const partWithPrint = find(measure.parts, part => !!part.staves[1] &&
options.modelFactory.search(part.staves[1], 0, Type.Print).length);
if (partWithPrint) {
return options.modelFactory.search(partWithPrint.staves[1], 0,
Type.Print)[0]._snapshot as any;
}
return null;
}
/**
* Reducer that puts measures into lines.
*/
function assignLinesReducer(memo: IReduceOptsMemo, measureInfo: ILinePlacementHint, idx: number, all: ILinePlacementHint[]):
IReduceOptsMemo {
let options = memo.options;
let measures = options.measures;
memo.thisPrint = findPrint(options, measures[idx]) || memo.thisPrint;
if (!last(memo.opts).print) {
last(memo.opts).print = memo.thisPrint;
}
invariant(!!memo.thisPrint, "No print found");
if (!memo.options.singleLineMode) {
if (measureInfo.attributesWidthStart > memo.widthAllocatedForStart) {
memo.remainingWidth -= measureInfo.attributesWidthStart - memo.widthAllocatedForStart;
memo.widthAllocatedForStart = measureInfo.attributesWidthStart;
}
if (measureInfo.attributesWidthEnd > memo.widthAllocatedForEnd) {
memo.remainingWidth -= measureInfo.attributesWidthEnd - memo.widthAllocatedForEnd;
memo.widthAllocatedForEnd = measureInfo.attributesWidthEnd;
}
let retroactiveIncrease = 0;
if (memo.shortest > measureInfo.shortestCount) {
const measuresOnLine = last(memo.opts).measures.length;
const measuresInfo = all.slice(idx - measuresOnLine, idx);
retroactiveIncrease = measuresInfo.reduce((increase, measure) => {
return measure.widthByShortest[measureInfo.shortestCount] - measure.widthByShortest[memo.shortest];
}, 0);
memo.shortest = measureInfo.shortestCount;
}
const measureWidth = measureInfo.widthByShortest[memo.shortest];
const totalIncrease = retroactiveIncrease + measureWidth;
if (memo.remainingWidth > totalIncrease) {
memo.remainingWidth -= totalIncrease;
} else {
memo.opts.push(createEmptyLayout(options, memo.thisPrint));
memo.remainingWidth = memo.startingWidth - measureWidth -
measureInfo.attributesWidthStart - measureInfo.attributesWidthEnd;
memo.widthAllocatedForStart = measureInfo.attributesWidthStart;
memo.widthAllocatedForEnd = measureInfo.attributesWidthEnd;
}
}
last(memo.opts).measures.push(measures[idx]);
return memo;
}
function createEmptyLayout(options: ILayoutOptions, print: Print): ILayoutOptions {
return {
...options,
attributes: null,
measures: [],
print: print,
};
}
export function getApproximateMeasureWidth(measure: IMeasure, shortest: number) {
return Object.keys(measure.parts).reduce((pwidth, partName) => {
const vwidth = measure.parts[partName].voices.reduce((vwidth, voice) => {
if (!voice) {
return vwidth;
}
return voice.reduce((swidth, el) => swidth + el.calcWidth(shortest), vwidth);
}, 0);
return Math.max(vwidth, pwidth);
}, 0);
}
function getLinePlacementHints(measures: IMeasure[]): ReadonlyArray<ILinePlacementHint> {
const shortestByMeasure: ReadonlyArray<number> = measures.map(measure => {
const segments = getMeasureSegments(measure);
return reduce(segments, reduceToShortestInSegments, Number.MAX_VALUE);
});
const shortestsObj: {readonly [key: number]: boolean} =
shortestByMeasure.reduce((shortests, shortest) => {
shortests[shortest] = true;
return shortests;
}, {} as {[key: number]: boolean});
const shortests = Object.keys(shortestsObj).map(str => parseInt(str, 10));
return map(measures, function layoutMeasure(measure, idx): ILinePlacementHint {
const shortestInMeasure = shortestByMeasure[idx];
let specifiedWidth = measure.width;
const numericMeasureWidth = !isNaN(measure.width) && measure.width !== null;
if (numericMeasureWidth && (measure.width <= 0 || !isFinite(measure.width))) {
console.warn("Bad measure width %s. Ignoring", measure.width);
specifiedWidth = undefined;
}
let widthByShortest = shortests.reduce((shortests, shortest) => {
if (shortest <= shortestInMeasure) {
shortests[shortest] = getApproximateMeasureWidth(measure, shortest);
}
return shortests;
}, {} as {[key: number]: number});
// XXX: multiple rests
return {
widthByShortest,
shortestCount: shortestInMeasure,
attributesWidthStart: 150, // XXX
attributesWidthEnd: 50, // XXX
};
});
}
export default function layoutSong(options: ILayoutOptions):
IMeasureLayout[][] {
invariant(!!options.print, "Print not defined");
invariant(!options.print._snapshot, "Pass a snapshot of Print to layoutSong, not the actual model!");
const page = 1; // XXX
const scaling = options.document.header.defaults.scaling;
// Estimate the width of each measure, and the space available for each line.
const boundsGuess = calculateLineBounds(options.print, page, scaling);
const lineWidth = (boundsGuess.right - boundsGuess.left) / SQUISHINESS;
const linePlacementHints = options.preview ?
options.document.cleanlinessTracking.linePlacementHints :
getLinePlacementHints(options.measures);
options.document.cleanlinessTracking.linePlacementHints = linePlacementHints;
// Assign measures to lines.
const layoutOpts = reduce(linePlacementHints, assignLinesReducer, {
options: options,
opts: <ILayoutOptions[]>[createEmptyLayout(options, options.print)],
remainingWidth: lineWidth,
shortest: Number.MAX_VALUE,
startingWidth: lineWidth,
thisPrint: options.print,
widthAllocatedForEnd: 0,
widthAllocatedForStart: 0,
}).opts;
layoutOpts.forEach((line, idx) => {
line.lineIndex = idx;
line.lineCount = layoutOpts.length;
line.attributes = {};
});
if (!options.preview) {
const oldLineCleanliness = options.document.cleanlinessTracking.lines || [];
const newLineCleanliness = layoutOpts.map(line => line.measures.map(measure => measure.uuid)) || [];
for (let i = 0; i < oldLineCleanliness.length || i < newLineCleanliness.length; ++i) {
const oldLine = oldLineCleanliness[i] || [];
const newLine = newLineCleanliness[i] || [];
const isDirty = !oldLine || !newLine || oldLine.length !== newLine.length || oldLine.some((m, k) => newLine[k] !== m);
if (isDirty) {
oldLine.concat(newLine).forEach(m => {
options.document.cleanlinessTracking.measures[m] = null;
});
}
}
options.document.cleanlinessTracking.lines = newLineCleanliness;
}
// Create the final layout
const memo = {
y: calculateLineBounds(layoutOpts[0].print, page, scaling).top,
attributes: {},
};
return layoutOpts.map(lineOpt => layoutLine(
lineOpt,
calculateLineBounds(lineOpt.print, page, scaling),
memo,
));
}