Add utils for text wrapping, originally by CWF

This commit is contained in:
Paul Kaplan
2017-10-04 09:37:20 -04:00
parent 0b3e7c37c2
commit ebe2db2e02
4 changed files with 224 additions and 0 deletions

View File

@@ -34,9 +34,12 @@
"eslint": "^4.6.1",
"eslint-config-scratch": "^4.0.0",
"gh-pages": "^1.0.0",
"grapheme-breaker": "0.3.2",
"hull.js": "0.2.10",
"ify-loader": "1.0.4",
"jsdoc": "^3.5.5",
"json": "^9.0.4",
"linebreak": "0.3.0",
"raw-loader": "^0.5.1",
"scratch-render-fonts": "git+https://github.com/LLK/scratch-render-fonts.git",
"tap": "^10.3.0",

View File

@@ -0,0 +1,104 @@
const TextWrapper = require('./text-wrapper');
/**
* Measure text by using a hidden SVG attached to the DOM.
* For use with TextWrapper.
*/
class SVGMeasurementProvider {
constructor () {
this._svgRoot = null;
this._cache = {};
}
/**
* Detach the hidden SVG element from the DOM and forget all references to it and its children.
*/
dispose () {
if (this._svgRoot) {
this._svgRoot.parentElement.removeChild(this._svgRoot);
this._svgRoot = null;
this._svgText = null;
}
}
/**
* Called by the TextWrapper before a batch of zero or more calls to measureText().
*/
beginMeasurementSession () {
if (!this._svgRoot) {
this._init();
}
}
/**
* Called by the TextWrapper after a batch of zero or more calls to measureText().
*/
endMeasurementSession () {
this._svgText.textContent = '';
this.dispose();
}
/**
* Measure a whole string as one unit.
* @param {string} text - the text to measure.
* @returns {number} - the length of the string.
*/
measureText (text) {
if (!this._cache[text]) {
this._svgText.textContent = text;
this._cache[text] = this._svgText.getComputedTextLength();
}
return this._cache[text];
}
/**
* Create a simple SVG containing a text node, hide it, and attach it to the DOM. The text node will be used to
* collect text measurements. The SVG must be attached to the DOM: otherwise measurements will generally be zero.
* @private
*/
_init () {
const svgNamespace = 'http://www.w3.org/2000/svg';
const svgRoot = document.createElementNS(svgNamespace, 'svg');
const svgGroup = document.createElementNS(svgNamespace, 'g');
const svgText = document.createElementNS(svgNamespace, 'text');
// Normalize text attributes to match what the svg-renderer does.
// @TODO This code should be shared with the svg-renderer.
svgText.setAttribute('alignment-baseline', 'text-before-edge');
svgText.setAttribute('font-size', '14');
svgText.setAttribute('font-family', 'Helvetica');
// hide from the user, including screen readers
svgRoot.setAttribute('style', 'position:absolute;visibility:hidden');
document.body.appendChild(svgRoot);
svgRoot.appendChild(svgGroup);
svgGroup.appendChild(svgText);
/**
* The root SVG element.
* @type {SVGSVGElement}
* @private
*/
this._svgRoot = svgRoot;
/**
* The leaf SVG element used for text measurement.
* @type {SVGTextElement}
* @private
*/
this._svgText = svgText;
}
}
/**
* TextWrapper specialized for SVG text.
*/
class SVGTextWrapper extends TextWrapper {
constructor () {
super(new SVGMeasurementProvider());
}
}
module.exports = SVGTextWrapper;

113
src/util/text-wrapper.js Normal file
View File

@@ -0,0 +1,113 @@
const LineBreaker = require('linebreak');
const GraphemeBreaker = require('grapheme-breaker');
/**
* Tell this text wrapper to use a specific measurement provider.
* @typedef {object} MeasurementProvider - the new measurement provider.
* @property {Function} beginMeasurementSession - this will be called before a batch of measurements are made.
* Optionally, this function may return an object to be provided to the endMeasurementSession function.
* @property {Function} measureText - this will be called each time a piece of text must be measured.
* @property {Function} endMeasurementSession - this will be called after a batch of measurements is finished.
* It will be passed whatever value beginMeasurementSession returned, if any.
*/
/**
* Utility to wrap text across several lines, respecting Unicode grapheme clusters and, when possible, Unicode line
* break opportunities.
* Reference material:
* - Unicode Standard Annex #14: http://unicode.org/reports/tr14/
* - Unicode Standard Annex #39: http://unicode.org/reports/tr29/
* - "JavaScript has a Unicode problem" by Mathias Bynens: https://mathiasbynens.be/notes/javascript-unicode
*/
class TextWrapper {
/**
* Construct a text wrapper which will measure text using the specified measurement provider.
* @param {MeasurementProvider} measurementProvider - a helper object to provide text measurement services.
*/
constructor (measurementProvider) {
this._measurementProvider = measurementProvider;
this._cache = {};
}
/**
* Wrap the provided text into lines restricted to a maximum width. See Unicode Standard Annex (UAX) #14.
* @param {number} maxWidth - the maximum allowed width of a line.
* @param {string} text - the text to be wrapped. Will be split on whitespace.
* @returns {Array.<string>} an array containing the wrapped lines of text.
*/
wrapText (maxWidth, text) {
// Normalize to canonical composition (see Unicode Standard Annex (UAX) #15)
text = text.normalize();
const cacheKey = `${maxWidth}-${text}`;
if (this._cache[cacheKey]) {
return this._cache[cacheKey];
}
const measurementSession = this._measurementProvider.beginMeasurementSession();
const breaker = new LineBreaker(text);
let lastPosition = 0;
let nextBreak;
let currentLine = null;
const lines = [];
while ((nextBreak = breaker.nextBreak())) {
const word = text.slice(lastPosition, nextBreak.position).replace(/\n+$/, '');
let proposedLine = (currentLine || '').concat(word);
let proposedLineWidth = this._measurementProvider.measureText(proposedLine);
if (proposedLineWidth > maxWidth) {
// The next word won't fit on this line. Will it fit on a line by itself?
const wordWidth = this._measurementProvider.measureText(word);
if (wordWidth > maxWidth) {
// The next word can't even fit on a line by itself. Consume it one grapheme cluster at a time.
let lastCluster = 0;
let nextCluster;
while (lastCluster !== (nextCluster = GraphemeBreaker.nextBreak(word, lastCluster))) {
const cluster = word.substring(lastCluster, nextCluster);
proposedLine = (currentLine || '').concat(cluster);
proposedLineWidth = this._measurementProvider.measureText(proposedLine);
if ((currentLine === null) || (proposedLineWidth <= maxWidth)) {
// first cluster of a new line or the cluster fits
currentLine = proposedLine;
} else {
// no more can fit
lines.push(currentLine);
currentLine = cluster;
}
lastCluster = nextCluster;
}
} else {
// The next word can fit on the next line. Finish the current line and move on.
if (currentLine !== null) lines.push(currentLine);
currentLine = word;
}
} else {
// The next word fits on this line. Just keep going.
currentLine = proposedLine;
}
// Did we find a \n or similar?
if (nextBreak.required) {
if (currentLine !== null) lines.push(currentLine);
currentLine = null;
}
lastPosition = nextBreak.position;
}
currentLine = currentLine || '';
if (currentLine.length > 0 || lines.length === 0) {
lines.push(currentLine);
}
this._cache[cacheKey] = lines;
this._measurementProvider.endMeasurementSession(measurementSession);
return lines;
}
}
module.exports = TextWrapper;

View File

@@ -20,6 +20,10 @@ const base = {
options: {
presets: ['es2015']
}
},
{
test: /node_modules\/(linebreak|grapheme-breaker)\/.*\.js$/,
loader: 'ify-loader'
}
]
},