Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hyphenation and Justification #44

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
91 changes: 91 additions & 0 deletions examples/text/app.js

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions examples/text/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ReactCanvas: text</title>
<link rel="stylesheet" type="text/css" href="/examples/common/examples.css">
<script src="/examples/common/touch-emulator.js"></script>
<script type="text/javascript">
TouchEmulator();
</script>
</head>
<body>
<div style="max-width: inherit; max-height: inherit" id="main"></div>
<script src="/build/text.js"></script>
</body>
</html>
83 changes: 62 additions & 21 deletions lib/CanvasUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,18 @@ function drawText (ctx, text, x, y, width, height, fontFace, options) {
options.textAlign = options.textAlign || 'left';
options.backgroundColor = options.backgroundColor || 'transparent';
options.color = options.color || '#000';
options.breakingStrategy = options.breakingStrategy || 'firstFit';
options.hyphens = options.hyphens || 'none';


textMetrics = measureText(
text,
width,
fontFace,
options.fontSize,
options.lineHeight
options.lineHeight,
options.hyphens,
options.breakingStrategy
);

ctx.save();
Expand All @@ -138,31 +143,67 @@ function drawText (ctx, text, x, y, width, height, fontFace, options) {
ctx.fillStyle = options.color;
ctx.font = fontFace.attributes.style + ' normal ' + fontFace.attributes.weight + ' ' + options.fontSize + 'pt ' + fontFace.family;

textMetrics.lines.forEach(function (line, index) {
currText = line.text;
currY = (index === 0) ? y + options.fontSize :
(y + options.fontSize + options.lineHeight * index);

// Account for text-align: left|right|center
switch (options.textAlign) {
case 'center':
currX = x + (width / 2) - (line.width / 2);
break;
case 'right':
currX = x + width - line.width;
break;
default:
currX = x;
}
textMetrics.lines.forEach(function (line, lineIdx, lines) {

if ((index < textMetrics.lines.length - 1) &&
((options.fontSize + options.lineHeight * (index + 1)) > height)) {
currText = currText.replace(/\,?\s?\w+$/, '…');
var currY = y + options.fontSize;
if (lineIdx !== 0) {
currY += options.lineHeight * lineIdx;
}

// only render if on screen
if (currY <= (height + y)) {
ctx.fillText(currText, currX, currY);

var text = line.words.map( function(word) { return word.text; });

if ((lineIdx < textMetrics.lines.length - 1) &&
((options.fontSize + options.lineHeight * (lineIdx + 1)) > height)) {
text.pop();
text[text.length - 1] += '…';
}

// Fast path. We can discard all width information and set one
// text run per line, allowing fillText() to handle spacing.
// Special case the last line of a justified paragraph.
if (options.textAlign !== 'justify' || lineIdx === lines.length - 1) {
currText = text.join(' ');
// Account for text-align: left|right|center
switch (options.textAlign) {
case 'center':
currX = x + (width / 2) - ((line.width + line.whiteSpace) / 2);
break;
case 'right':
currX = x + width - line.width - line.whiteSpace;
break;
default:
currX = x;
}

ctx.fillText(currText, currX, currY);

// Slow path. Full justification. Set each word individually.
} else {
var glueWidth = (textMetrics.width - line.width) / (line.words.length - 1);

var widths = line.words.map( function(word) {
return word.width;
});

// This is slightly noisy in JavaScript. Compare Haskell:
// advanceWidths = scanl (\x y -> glueWidth + x + y) 0 widths
// or Clojure:
// (def advanceWidths (reductions #(+ glueWidth %1 %2) 0 widths))
var advanceWidths = widths.reduce(function(memo, width) {
memo.push(memo[memo.length - 1] + glueWidth + width);
return memo;
}, [0]);

text.forEach(function(word, wordIdx) {
ctx.fillText(word, x + advanceWidths[wordIdx], currY);
});
}

}

});

ctx.restore();
Expand Down
3 changes: 2 additions & 1 deletion lib/DrawingUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,8 @@ function drawTextRenderLayer (ctx, layer) {
fontSize: layer.fontSize,
lineHeight: layer.lineHeight,
textAlign: layer.textAlign,
color: layer.color
color: layer.color,
hyphens: layer.hyphens
});
}

Expand Down
1 change: 1 addition & 0 deletions lib/Text.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var Text = createComponent('Text', LayerMixin, {
layer.fontSize = style.fontSize;
layer.lineHeight = style.lineHeight;
layer.textAlign = style.textAlign;
layer.hyphens = style.hyphens;
},

mountComponent: function (rootID, transaction, context) {
Expand Down
214 changes: 143 additions & 71 deletions lib/measureText.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,88 +6,160 @@ var FontUtils = require('./FontUtils');
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');

var _cache = {};
var _zeroMetrics = {
width: 0,
height: 0,
lines: []
var Hypher = require('hypher'); // FIXME: Lazy load
var english = require('hyphenation.en-us'); // FIXME: l10n
var h = new Hypher(english);

var _fragmentWidthMemos = {};
var measureFragmentWidth = function (fragment, fontFace, fontSize) {
var memoKey = fragment + fontFace.id + fontSize;
var memoized = _fragmentWidthMemos[memoKey];
if (memoized) { return memoized; }

ctx.font = fontFace.attributes.style + ' normal ' +
fontFace.attributes.weight + ' ' + fontSize + 'pt ' +
fontFace.family;
var fragmentWidth = ctx.measureText(fragment).width;

_fragmentWidthMemos[memoKey] = fragmentWidth;
return fragmentWidth;
};

function splitText (text) {
return text.split(' ');
}

function getCacheKey (text, width, fontFace, fontSize, lineHeight) {
return text + width + fontFace.id + fontSize + lineHeight;
}

/**
* Given a string of text, available width, and font return the measured width
* and height.
* @param {String} text The input string
* @param {Number} width The available width
* @param {FontFace} fontFace The FontFace to use
* @param {Number} fontSize The font size in CSS pixels
* @param {Number} lineHeight The line height in CSS pixels
* @return {Object} Measured text size with `width` and `height` members.
*/
module.exports = function measureText (text, width, fontFace, fontSize, lineHeight) {
var cacheKey = getCacheKey(text, width, fontFace, fontSize, lineHeight);
var cached = _cache[cacheKey];
if (cached) {
return cached;
var _hyphenationOpportunityMemos = {};
var getHyphenationOpportunities = function(text, hyphens) {
var memoKey = hyphens + text;
var memoized = _hyphenationOpportunityMemos[memoKey];
if (memoized) { return memoized; }

var words = text.split(/\s+/);
if (hyphens === 'auto') {
words = words.map(function(word) {
return h.hyphenate(word);
});
} else {
words = words.map(function(word) {
return [word];
});
}

_hyphenationOpportunityMemos[memoKey] = words;
return words;
};

var _paragraphMetricsMemos = {};
var getParagraphMetrics = function(text, hyphens, fontFace, fontSize) {
var memoKey = text + hyphens + fontFace.id + fontSize;
var memoized = _paragraphMetricsMemos[memoKey];
if (memoized) { return memoized; }

var metrics = {};
metrics.fragments = getHyphenationOpportunities(text, hyphens);
metrics.fragmentWidths = metrics.fragments.map(function(word) {
return word.map (function(morpheme) {
return measureFragmentWidth(morpheme, fontFace, fontSize);
});
});
metrics.spaceWidth = measureFragmentWidth(' ', fontFace, fontSize);
metrics.hyphenWidth = measureFragmentWidth('-', fontFace, fontSize);

_paragraphMetricsMemos[memoKey] = metrics;
return metrics;
};

var firstFit = function(maxWidth, metrics) {
function Word() {
this.text = '';
this.width = 0;
}

function Line() {
this.whiteSpace = 0;
this.width = 0;
this.words = [ new Word() ];
}

var lines = [ new Line() ];

Line.prototype.appendMorpheme = function(morpheme, advanceWidth) {
var word = this.words[this.words.length - 1];
word.text += morpheme;
word.width += advanceWidth;
this.width += advanceWidth;
};

Line.prototype.appendHyphen = function() {
this.appendMorpheme('-', metrics.hyphenWidth);
};

Line.prototype.appendSpace = function() {
this.whiteSpace += metrics.spaceWidth;
};

Line.prototype.newWord = function() {
this.appendSpace();
this.words.push( new Word() );
};

function push(morpheme, advanceWidth, initial, final) {
var line = lines[lines.length - 1];
// setting the first morpheme of a line always succeeds
if (line.width === 0) {
// good to go!
// do we need to break the line?
} else if
// handle a middle syllable
((!initial && !final &&
line.width + line.whiteSpace + advanceWidth + metrics.hyphenWidth > maxWidth) ||
// handle an initial syllable
(initial &&
line.width + line.whiteSpace + metrics.spaceWidth + advanceWidth > maxWidth) ||
// handle a final syllable.
(!initial && final &&
line.width + line.whiteSpace + advanceWidth > maxWidth)) {
if (!initial) { line.appendHyphen(); }
line = new Line();
lines.push(line);
} else if (initial) {
line.newWord();
}

line.appendMorpheme(morpheme, advanceWidth);
}

metrics.fragments.forEach(function(word, wordIdx, words) {
word.forEach(function(morpheme, morphemeIdx) {
var advanceWidth = metrics.fragmentWidths[wordIdx][morphemeIdx];
var initial = (morphemeIdx === 0);
var final = (morphemeIdx === word.length - 1);
push(morpheme, advanceWidth, initial, final);
});
});

return lines;
};

// var _lineBreakMemos = {};
module.exports = function measureText(text, width, fontFace, fontSize, lineHeight, hyphens, breakingStrategy) {
// Bail and return zero unless we're sure the font is ready.
if (!FontUtils.isFontLoaded(fontFace)) {
return _zeroMetrics;
return { width: 0, height: 0, lines: [] };
}

// var memoKey = text + hyphens + fontFace.id + fontSize + width + breakingStrategy;
// var memoized = _lineBreakMemos[memoKey];
// if (memoized) { return memoized; }

var metrics = getParagraphMetrics(text, hyphens, fontFace, fontSize);
var measuredSize = {};
var textMetrics;
var lastMeasuredWidth;
var words;
var tryLine;
var currentLine;

ctx.font = fontFace.attributes.style + ' normal ' + fontFace.attributes.weight + ' ' + fontSize + 'pt ' + fontFace.family;
textMetrics = ctx.measureText(text);

measuredSize.width = textMetrics.width;
measuredSize.height = lineHeight;
measuredSize.lines = [];

if (measuredSize.width <= width) {
// The entire text string fits.
measuredSize.lines.push({width: measuredSize.width, text: text});
measuredSize.width = width;

if (!breakingStrategy || breakingStrategy === 'firstFit') {
measuredSize.lines = firstFit(width, metrics);
} else {
// Break into multiple lines.
measuredSize.width = width;
words = splitText(text);
currentLine = '';

// This needs to be optimized!
while (words.length) {
tryLine = currentLine + words[0] + ' ';
textMetrics = ctx.measureText(tryLine);
if (textMetrics.width > width) {
measuredSize.height += lineHeight;
measuredSize.lines.push({width: lastMeasuredWidth, text: currentLine.trim()});
currentLine = words[0] + ' ';
lastMeasuredWidth = ctx.measureText(currentLine.trim()).width;
} else {
currentLine = tryLine;
lastMeasuredWidth = textMetrics.width;
}
if (words.length === 1) {
textMetrics = ctx.measureText(currentLine.trim());
measuredSize.lines.push({width: textMetrics.width, text: currentLine.trim()});
}
words.shift();
}
throw 'TODO: implement global fit linebreaking';
}

_cache[cacheKey] = measuredSize;

measuredSize.height = measuredSize.lines.length * lineHeight;
// _lineBreakMemos[memoKey] = measuredSize;
return measuredSize;
};
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"react": "^0.13.0-beta.1"
},
"dependencies": {
"scroller": "git://github.com/mjohnston/scroller"
"scroller": "git://github.com/mjohnston/scroller",
"hypher": "0.2.3",
"hyphenation.en-us": "0.2.1"
}
}
3 changes: 2 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ module.exports = {
entry: {
'listview': ['./examples/listview/app.js'],
'timeline': ['./examples/timeline/app.js'],
'css-layout': ['./examples/css-layout/app.js']
'css-layout': ['./examples/css-layout/app.js'],
'text': ['./examples/text/app.js']
},

output: {
Expand Down