blossom
Version:
Modern, Cross-Platform Application Framework
289 lines (253 loc) • 9.22 kB
JavaScript
/*global LinkedList*/
sc_require('text/object');
sc_require('text/array');
sc_require('text/linked_list');
/*!
* Knuth and Plass line breaking algorithm in JavaScript
*
* Licensed under the new BSD License.
* Copyright 2009-2010, Bram Stein
* All rights reserved.
*/
var linebreak = function (nodes, lines, settings) {
var options = Object.extend({}, linebreak.defaults, settings),
activeNodes = new LinkedList(),
sum = {
width: 0,
stretch: 0,
shrink: 0
},
lineLengths = lines,
breaks = [],
tmp = {
data: {
demerits: Infinity
}
};
function breakpoint(position, demerits, ratio, line, fitnessClass, totals, previous) {
return {
position: position,
demerits: demerits,
ratio: ratio,
line: line,
fitnessClass: fitnessClass,
totals: totals || {
width: 0,
stretch: 0,
shrink: 0
},
previous: previous
};
}
function computeCost(start, end, active, currentLine) {
var width = sum.width - active.totals.width,
stretch = 0,
shrink = 0,
// If the current line index is within the list of linelengths, use it, otherwise use
// the last line length of the list.
lineLength = currentLine < lineLengths.length ? lineLengths[currentLine - 1] : lineLengths[lineLengths.length - 1];
if (nodes[end].type === 'penalty') {
width += nodes[end].width;
}
if (width < lineLength) {
// Calculate the stretch ratio
stretch = sum.stretch - active.totals.stretch;
if (stretch > 0) {
return (lineLength - width) / stretch;
} else {
return options.infinity;
}
} else if (width > lineLength) {
// Calculate the shrink ratio
shrink = sum.shrink - active.totals.shrink;
if (shrink > 0) {
return (lineLength - width) / shrink;
} else {
return options.infinity;
}
} else {
// perfect match
return 0;
}
}
// Add width, stretch and shrink values from the current
// break point up to the next box or forced penalty.
function computeSum(breakPointIndex) {
var result = {
width: sum.width,
stretch: sum.stretch,
shrink: sum.shrink
},
i = 0;
for (i = breakPointIndex; i < nodes.length; i += 1) {
if (nodes[i].type === 'glue') {
result.width += nodes[i].width;
result.stretch += nodes[i].stretch;
result.shrink += nodes[i].shrink;
} else if (nodes[i].type === 'box' || (nodes[i].type === 'penalty' && nodes[i].penalty === -options.infinity && i > breakPointIndex)) {
break;
}
}
return result;
}
// The main loop of the algorithm
function mainLoop(node, index, nodes) {
var active = activeNodes.first(),
next = null,
ratio = 0,
demerits = 0,
candidates = [],
badness,
currentLine = 0,
tmpSum,
currentClass = 0;
// The inner loop iterates through all the active nodes with line < currentLine and then
// breaks out to insert the new active node candidates before looking at the next active
// nodes for the next lines. The result of this is that the active node list is always
// sorted by line number.
while (active !== null) {
candidates = [{demerits: Infinity}, {demerits: Infinity}, {demerits: Infinity}, {demerits: Infinity}];
// Iterate through the linked list of active nodes to find new potential active nodes
// and deactivate current active nodes.
while (active !== null) {
next = active.next;
currentLine = active.data.line + 1;
ratio = computeCost(active.data.position, index, active.data, currentLine);
// Deactive nodes when the the distance between the current active node and the
// current node becomes too large (i.e. it exceeds the stretch limit and the stretch
// ratio becomes negative) or when the current node is a forced break (i.e. the end
// of the paragraph when we want to remove all active nodes, but possibly have a final
// candidate active node---if the paragraph can be set using the given tolerance value.)
if (ratio < -1 || (node.type === 'penalty' && node.penalty === -options.infinity)) {
activeNodes.remove(active);
}
// If the ratio is within the valid range of -1 <= ratio <= tolerance calculate the
// total demerits and record a candidate active node.
if (-1 <= ratio && ratio <= options.tolerance) {
badness = 100 * Math.pow(Math.abs(ratio), 3);
// Positive penalty
if (node.type === 'penalty' && node.penalty >= 0) {
demerits = Math.pow(options.demerits.line + badness + node.penalty, 2);
// Negative penalty but not a forced break
} else if (node.type === 'penalty' && node.penalty !== -options.infinity) {
demerits = Math.pow(options.demerits.line + badness - node.penalty, 2);
// All other cases
} else {
demerits = Math.pow(options.demerits.line + badness, 2);
}
if (node.type === 'penalty' && nodes[active.data.position].type === 'penalty') {
demerits += options.demerits.flagged * node.flagged * nodes[active.data.position].flagged;
}
// Calculate the fitness class for this candidate active node.
if (ratio < -0.5) {
currentClass = 0;
} else if (ratio <= 0.5) {
currentClass = 1;
} else if (ratio <= 1) {
currentClass = 2;
} else {
currentClass = 3;
}
// Add a fitness penalty to the demerits if the fitness classes of two adjacent lines
// differ too much.
if (Math.abs(currentClass - active.data.fitnessClass) > 1) {
demerits += options.demerits.fitness;
}
// Add the total demerits of the active node to get the total demerits of this candidate node.
demerits += active.data.demerits;
// Only store the best candidate for each fitness class
if (demerits < candidates[currentClass].demerits) {
candidates[currentClass] = {active: active, demerits: demerits, ratio: ratio};
}
}
active = next;
// Stop iterating through active nodes to insert new candidate active nodes in the active list
// before moving on to the active nodes for the next line.
// TODO: The Knuth and Plass paper suggests a conditional for currentLine < j0. This means paragraphs
// with identical line lengths will not be sorted by line number. Find out if that is a desirable outcome.
// For now I left this out, as it only adds minimal overhead to the algorithm and keeping the active node
// list sorted has a higher priority.
if (active !== null && active.data.line >= currentLine) {
break;
}
}
tmpSum = computeSum(index);
candidates.forEach(function (candidate, fitnessClass) {
var newNode;
if (candidate.demerits < Infinity) {
newNode = new LinkedList.Node(breakpoint(index, candidate.demerits, candidate.ratio,
candidate.active.data.line + 1, fitnessClass, tmpSum, candidate.active));
if (active !== null) {
activeNodes.insertBefore(active, newNode);
} else {
activeNodes.push(newNode);
}
}
});
}
}
// Add an active node for the start of the paragraph.
activeNodes.push(new LinkedList.Node(breakpoint(0, 0, 0, 0, 0, undefined, null)));
nodes.forEach(function (node, index, nodes) {
if (node.type === 'box') {
sum.width += node.width;
} else if (node.type === 'glue') {
if (index > 0 && nodes[index - 1].type === 'box') {
mainLoop(node, index, nodes);
}
sum.width += node.width;
sum.stretch += node.stretch;
sum.shrink += node.shrink;
} else if (node.type === 'penalty' && node.penalty !== options.infinity) {
mainLoop(node, index, nodes);
}
});
if (activeNodes.size() !== 0) {
// Find the best active node (the one with the least total demerits.)
activeNodes.forEach(function (node) {
if (node.data.demerits < tmp.data.demerits) {
tmp = node;
}
});
while (tmp !== null) {
breaks.push({position: tmp.data.position, ratio: tmp.data.ratio});
tmp = tmp.data.previous;
}
return breaks.reverse();
}
return [];
};
Object.extend(linebreak, {
defaults: {
demerits: {
line: 10,
flagged: 100,
fitness: 3000
},
infinity: 10000,
tolerance: 2
},
glue: function (width, stretch, shrink) {
return {
type: 'glue',
width: width,
stretch: stretch,
shrink: shrink
};
},
box: function (width, value) {
return {
type: 'box',
width: width,
value: value
};
},
penalty: function (width, penalty, flagged) {
return {
type: 'penalty',
width: width,
penalty: penalty,
flagged: flagged
};
}
});