diff --git a/package.json b/package.json index ff2a372..f160af5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/util/svg-text-wrapper.js b/src/util/svg-text-wrapper.js new file mode 100644 index 0000000..f4a4feb --- /dev/null +++ b/src/util/svg-text-wrapper.js @@ -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; diff --git a/src/util/text-wrapper.js b/src/util/text-wrapper.js new file mode 100644 index 0000000..2673c40 --- /dev/null +++ b/src/util/text-wrapper.js @@ -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.} 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; diff --git a/webpack.config.js b/webpack.config.js index 9bbe61a..933bdec 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,6 +20,10 @@ const base = { options: { presets: ['es2015'] } + }, + { + test: /node_modules\/(linebreak|grapheme-breaker)\/.*\.js$/, + loader: 'ify-loader' } ] },