Source: render/TextRenderer.js

/*
 * Copyright 2003-2006, 2009, 2017, United States Government, as represented by the Administrator of the
 * National Aeronautics and Space Administration. All rights reserved.
 *
 * The NASAWorldWind/WebWorldWind platform is licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
/**
 * @exports TextRenderer
 */
define([
        '../error/ArgumentError',
        '../shaders/BasicTextureProgram',
        '../util/Color',
        '../util/Font',
        '../util/Logger',
        '../geom/Matrix',
        '../render/Texture',
        '../geom/Vec2'
    ],
    function (ArgumentError,
              BasicTextureProgram,
              Color,
              Font,
              Logger,
              Matrix,
              Texture,
              Vec2) {
        "use strict";

        /**
         * Constructs a TextRenderer instance.
         * @alias TextRenderer
         * @constructor
         * @classdesc Provides methods useful for displaying text. An instance of this class is attached to the
         * WorldWindow {@link DrawContext} and is not intended to be used independently of that. Applications typically do
         * not create instances of this class.
         * @param {drawContext} drawContext The current draw context. Typically the same draw context that TextRenderer
         * is attached to.
         * @throws {ArgumentError} If the specified draw context is null or undefined.
         */
        var TextRenderer = function (drawContext) {
            if (!drawContext) {
                throw new ArgumentError(Logger.logMessage(Logger.LEVEL_SEVERE, "TextRenderer", "constructor",
                    "missingDc"));
            }

            // Internal use only. Intentionally not documented.
            this.canvas2D = document.createElement("canvas");

            // Internal use only. Intentionally not documented.
            this.ctx2D = this.canvas2D.getContext("2d");

            // Internal use only. Intentionally not documented.
            this.dc = drawContext;

            /**
             * Indicates if the text will feature an outline around its characters.
             * @type {boolean}
             */
            this.enableOutline = true;

            // Internal use only. Intentionally not documented.
            this.lineSpacing = 0.15; // fraction of font size

            /**
             * The color for the Text outline.
             * Its default has half transparency to avoid visual artifacts that appear while fully opaque.
             * @type {Color}
             */
            this.outlineColor = new Color(0, 0, 0, 0.5);

            /**
             * Indicates the text outline width (or thickness) in pixels.
             * @type {number}
             */
            this.outlineWidth = 4;

            /**
             * The text color.
             * @type {Color}
             */
            this.textColor = new Color(1, 1, 1, 1);

            /**
             * The text size, face and other characteristics, as described in [Font]{@link Font}.
             * @type {Font}
             */
            this.typeFace = new Font(14);
        };

        /**
         * Returns the width and height of a specified text string considering the current typeFace and outline usage.
         * @param {string} text The text string.
         * @returns {Vec2} A vector indicating the text's width and height, respectively, in pixels.
         */
        TextRenderer.prototype.textSize = function (text) {
            if (text.length === 0) {
                return new Vec2(0, 0);
            }

            this.ctx2D.font = this.typeFace.fontString;

            var lines = text.split("\n"),
                height = lines.length * (this.typeFace.size * (1 + this.lineSpacing)),
                maxWidth = 0;

            for (var i = 0; i < lines.length; i++) {
                maxWidth = Math.max(maxWidth, this.ctx2D.measureText(lines[i]).width);
            }

            if (this.enableOutline) {
                maxWidth += this.outlineWidth;
                height += this.outlineWidth;
            }

            return new Vec2(maxWidth, height);
        };

        /**
         * Creates a texture for a specified text string and current TextRenderer state.
         * @param {String} text The text string.
         * @returns {Texture} A texture for the specified text string.
         */
        TextRenderer.prototype.renderText = function (text) {
            if (text && text.length > 0) {
                var canvas2D = this.drawText(text);
                return new Texture(this.dc.currentGlContext, canvas2D);
            } else {
                return null;
            }
        };

        /**
         * Creates a 2D Canvas for a specified text string while considering current TextRenderer state in
         * regards to outline usage and color, text color, typeface, and outline width.
         * @param {String} text The text string.
         * @returns {canvas2D} A 2D Canvas for the specified text string.
         */
        TextRenderer.prototype.drawText = function (text) {
            var ctx2D = this.ctx2D,
                canvas2D = this.canvas2D,
                textSize = this.textSize(text),
                lines = text.split("\n"),
                strokeOffset = this.enableOutline ? this.outlineWidth / 2 : 0,
                pixelScale = this.dc.pixelScale;

            canvas2D.width = Math.ceil(textSize[0]) * pixelScale;
            canvas2D.height = Math.ceil(textSize[1]) * pixelScale;

            ctx2D.scale(pixelScale, pixelScale);
            ctx2D.font = this.typeFace.fontString;
            ctx2D.textBaseline = "bottom";
            ctx2D.textAlign = this.typeFace.horizontalAlignment;
            ctx2D.fillStyle = this.textColor.toCssColorString();
            ctx2D.strokeStyle = this.outlineColor.toCssColorString();
            ctx2D.lineWidth = this.outlineWidth;
            ctx2D.lineCap = "round";
            ctx2D.lineJoin = "round";

            if (this.typeFace.horizontalAlignment === "left") {
                ctx2D.translate(strokeOffset, 0);
            } else if (this.typeFace.horizontalAlignment === "right") {
                ctx2D.translate(textSize[0] - strokeOffset, 0);
            } else {
                ctx2D.translate(textSize[0] / 2, 0);
            }

            for (var i = 0; i < lines.length; i++) {
                ctx2D.translate(0, this.typeFace.size * (1 + this.lineSpacing) + strokeOffset);
                if (this.enableOutline) {
                    ctx2D.strokeText(lines[i], 0, 0);
                }
                ctx2D.fillText(lines[i], 0, 0);
            }

            return canvas2D;
        };

        /**
         * Calculates maximum line height based on the current typeFace and outline usage of TextRenderer.
         * @returns {Vec2} A vector indicating the text's width and height, respectively, in pixels.
         */
        TextRenderer.prototype.getMaxLineHeight = function () {
            // Check underscore + capital E with acute accent
            return this.textSize("_\u00c9")[1];
        };

        /**
         * Wraps the text based on width and height using new line delimiter
         * @param {String} text The text to wrap.
         * @param {Number} width The width in pixels.
         * @param {Number} height The height in pixels.
         * @returns {String} The wrapped text.
         */
        TextRenderer.prototype.wrap = function (text, width, height) {
            if (!text) {
                throw new ArgumentError(
                    Logger.logMessage(Logger.WARNING, "TextRenderer", "wrap", "missing text"));
            }

            var i;

            var lines = text.split("\n");
            var wrappedText = "";

            // Wrap each line
            for (i = 0; i < lines.length; i++) {
                lines[i] = this.wrapLine(lines[i], width);
            }
            // Concatenate all lines in one string with new line separators
            // between lines - not at the end
            // Checks for height limit.
            var currentHeight = 0;
            var heightExceeded = false;
            var maxLineHeight = this.getMaxLineHeight();
            for (i = 0; i < lines.length && !heightExceeded; i++) {
                var subLines = lines[i].split("\n");
                for (var j = 0; j < subLines.length && !heightExceeded; j++) {
                    if (height <= 0 || currentHeight + maxLineHeight <= height) {
                        wrappedText += subLines[j];
                        currentHeight += maxLineHeight + this.lineSpacing;
                        if (j < subLines.length - 1) {
                            wrappedText += '\n';
                        }
                    }
                    else {
                        heightExceeded = true;
                    }
                }

                if (i < lines.length - 1 && !heightExceeded) {
                    wrappedText += '\n';
                }
            }
            // Add continuation string if text truncated
            if (heightExceeded) {
                if (wrappedText.length > 0) {
                    wrappedText = wrappedText.substring(0, wrappedText.length - 1);
                }

                wrappedText += "...";
            }

            return wrappedText;
        };

        /**
         * Wraps a line of text based on width and height
         * @param {String} text The text to wrap.
         * @param {Number} width The width in pixels.
         * @returns {String} The wrapped text.
         */
        TextRenderer.prototype.wrapLine = function (text, width) {
            var wrappedText = "";

            // Single line - trim leading and trailing spaces
            var source = text.trim();
            var lineBounds = this.textSize(source);
            if (lineBounds[0] > width) {
                // Split single line to fit preferred width
                var line = "";
                var start = 0;
                var end = source.indexOf(' ', start + 1);
                while (start < source.length) {
                    if (end === -1) {
                        end = source.length;   // last word
                    }

                    // Extract a 'word' which is in fact a space and a word
                    var word = source.substring(start, end);
                    var linePlusWord = line + word;
                    if (this.textSize(linePlusWord)[0] <= width) {
                        // Keep adding to the current line
                        line += word;
                    }
                    else {
                        // Width exceeded
                        if (line.length !== 0) {
                            // Finish current line and start new one
                            wrappedText += line;
                            wrappedText += '\n';
                            line = "";
                            line += word.trim();  // get read of leading space(s)
                        }
                        else {
                            // Line is empty, force at least one word
                            line += word.trim();
                        }
                    }
                    // Move forward in source string
                    start = end;
                    if (start < source.length - 1) {
                        end = source.indexOf(' ', start + 1);
                    }
                }
                // Gather last line
                wrappedText += line;
            }
            else {
                // Line doesn't need to be wrapped
                wrappedText += source;
            }

            return wrappedText;
        };

        return TextRenderer;
    });