Skip to content
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