UNPKG

@d3plus/text

Version:

A smart SVG text box with line wrapping and automatic font size scaling.

482 lines (450 loc) 15.4 kB
/* @d3plus/text v3.0.5 A smart SVG text box with line wrapping and automatic font size scaling. Copyright (c) 2025 D3plus - https://d3plus.org @license MIT */ (function (factory) { typeof define === 'function' && define.amd ? define(factory) : factory(); })((function () { 'use strict'; if (typeof window !== "undefined") { (function () { try { if (typeof SVGElement === 'undefined' || Boolean(SVGElement.prototype.innerHTML)) { return; } } catch (e) { return; } function serializeNode (node) { switch (node.nodeType) { case 1: return serializeElementNode(node); case 3: return serializeTextNode(node); case 8: return serializeCommentNode(node); } } function serializeTextNode (node) { return node.textContent.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } function serializeCommentNode (node) { return '<!--' + node.nodeValue + '-->' } function serializeElementNode (node) { var output = ''; output += '<' + node.tagName; if (node.hasAttributes()) { [].forEach.call(node.attributes, function(attrNode) { output += ' ' + attrNode.name + '="' + attrNode.value + '"'; }); } output += '>'; if (node.hasChildNodes()) { [].forEach.call(node.childNodes, function(childNode) { output += serializeNode(childNode); }); } output += '</' + node.tagName + '>'; return output; } Object.defineProperty(SVGElement.prototype, 'innerHTML', { get: function () { var output = ''; [].forEach.call(this.childNodes, function(childNode) { output += serializeNode(childNode); }); return output; }, set: function (markup) { while (this.firstChild) { this.removeChild(this.firstChild); } try { var dXML = new DOMParser(); dXML.async = false; var sXML = '<svg xmlns=\'http://www.w3.org/2000/svg\' xmlns:xlink=\'http://www.w3.org/1999/xlink\'>' + markup + '</svg>'; var svgDocElement = dXML.parseFromString(sXML, 'text/xml').documentElement; [].forEach.call(svgDocElement.childNodes, function(childNode) { this.appendChild(this.ownerDocument.importNode(childNode, true)); }.bind(this)); } catch (e) { throw new Error('Error parsing markup string'); } } }); Object.defineProperty(SVGElement.prototype, 'innerSVG', { get: function () { return this.innerHTML; }, set: function (markup) { this.innerHTML = markup; } }); })(); } })); (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('linebreak'), require('@d3plus/dom')) : typeof define === 'function' && define.amd ? define('@d3plus/text', ['exports', 'linebreak', '@d3plus/dom'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.d3plus = {}, global.LineBreaker, global.dom)); })(this, (function (exports, LineBreaker, dom) { 'use strict'; /** @const fontFamily @desc The default fallback font list used for all text labels as an Array of Strings. @returns {Array<string>} */ const fontFamily = [ "Inter", "Helvetica Neue", "HelveticaNeue", "Helvetica", "Arial", "sans-serif" ]; /** @const fontFamilyStringify @desc Converts an Array of font-family names into a CSS font-family string. @param {String|Array<string>} *family* @returns {String} */ const fontFamilyStringify = (family)=>(typeof family === "string" ? [ family ] : family).map((d)=>d.match(/^[a-z-_]{1,}$/) ? d : `'${d}'`).join(", "); /** @function stringify @desc Coerces value into a String. @param {String} value */ function stringify(value) { if (value === void 0) value = "undefined"; else if (!(typeof value === "string" || value instanceof String)) value = JSON.stringify(value); return value; } // great unicode list: http://asecuritysite.com/coding/asc2 const diacritics = [ [ /[\300-\305]/g, "A" ], [ /[\340-\345]/g, "a" ], [ /[\306]/g, "AE" ], [ /[\346]/g, "ae" ], [ /[\337]/g, "B" ], [ /[\307]/g, "C" ], [ /[\347]/g, "c" ], [ /[\320\336\376]/g, "D" ], [ /[\360]/g, "d" ], [ /[\310-\313]/g, "E" ], [ /[\350-\353]/g, "e" ], [ /[\314-\317]/g, "I" ], [ /[\354-\357]/g, "i" ], [ /[\321]/g, "N" ], [ /[\361]/g, "n" ], [ /[\u014c\322-\326\330]/g, "O" ], [ /[\u014d\362-\366\370]/g, "o" ], [ /[\u016a\331-\334]/g, "U" ], [ /[\u016b\371-\374]/g, "u" ], [ /[\327]/g, "x" ], [ /[\335]/g, "Y" ], [ /[\375\377]/g, "y" ] ]; /** @function strip @desc Removes all non ASCII characters from a string. @param {String} value @param {String} [spacer = "-"] */ function strip(value, spacer = "-") { return `${value}`.replace(/[^A-Za-z0-9\-_\u0621-\u064A]/g, (char)=>{ if (char === " ") return spacer; let ret = false; for(let d = 0; d < diacritics.length; d++){ if (new RegExp(diacritics[d][0]).test(char)) { ret = diacritics[d][1]; break; } } return ret || ""; }); } /** @function textSplit @desc Splits a given sentence into an array of words. @param {String} sentence */ function textSplit(sentence) { const breaker = new LineBreaker(sentence); let bk, last, words = []; while(bk = breaker.nextBreak()){ const word = sentence.slice(last, bk.position); words.push(word); last = bk.position; } return words; } /** @function trim @desc Cross-browser implementation of [trim](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim). @param {String} str */ function trim(str) { return str.toString().replace(/^\s+|\s+$/g, ""); } /** @function trimLeft @desc Cross-browser implementation of [trimLeft](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/TrimLeft). @param {String} str */ function trimLeft(str) { return str.toString().replace(/^\s+/, ""); } /** @function trimRight @desc Cross-browser implementation of [trimRight](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/TrimRight). @param {String} str */ function trimRight(str) { return str.toString().replace(/\s+$/, ""); } /** @function textWrap @desc Based on the defined styles and dimensions, breaks a string into an array of strings for each line of text. */ function textWrap() { let fontFamily = "sans-serif", fontSize = 10, fontWeight = 400, height = 200, lineHeight, maxLines = null, overflow = false, split = textSplit, width = 200; /** The inner return object and wraps the text and returns the line data array. @private */ function textWrap(sentence) { sentence = stringify(sentence); if (lineHeight === void 0) lineHeight = Math.ceil(fontSize * 1.4); const words = split(sentence); const style = { "font-family": fontFamily, "font-size": fontSize, "font-weight": fontWeight, "line-height": lineHeight }; let line = 1, textProg = "", truncated = false, widthProg = 0; const lineData = [], sizes = dom.textWidth(words, style); for(let i = 0; i < words.length; i++){ let word = words[i]; const wordWidth = sizes[words.indexOf(word)]; // newline if breaking character or not enough width if (textProg.slice(-1) === "\n" || widthProg + wordWidth > width) { if (!i && !overflow) { truncated = true; break; } if (lineData.length >= line) lineData[line - 1] = trimRight(lineData[line - 1]); line++; if (lineHeight * line > height || wordWidth > width && !overflow || maxLines && line > maxLines) { truncated = true; break; } widthProg = 0; lineData.push(word); } else if (!i) lineData[0] = word; else lineData[line - 1] += word; textProg += word; widthProg += wordWidth; } return { lines: lineData, sentence, truncated, widths: dom.textWidth(lineData, style), words }; } /** @memberof textWrap @desc If *value* is specified, sets the font family accessor to the specified function or string and returns this generator. If *value* is not specified, returns the current font family. @param {Function|String} [*value* = "sans-serif"] */ textWrap.fontFamily = function(_) { return arguments.length ? (fontFamily = _, textWrap) : fontFamily; }; /** @memberof textWrap @desc If *value* is specified, sets the font size accessor to the specified function or number and returns this generator. If *value* is not specified, returns the current font size. @param {Function|Number} [*value* = 10] */ textWrap.fontSize = function(_) { return arguments.length ? (fontSize = _, textWrap) : fontSize; }; /** @memberof textWrap @desc If *value* is specified, sets the font weight accessor to the specified function or number and returns this generator. If *value* is not specified, returns the current font weight. @param {Function|Number|String} [*value* = 400] */ textWrap.fontWeight = function(_) { return arguments.length ? (fontWeight = _, textWrap) : fontWeight; }; /** @memberof textWrap @desc If *value* is specified, sets height limit to the specified value and returns this generator. If *value* is not specified, returns the current value. @param {Number} [*value* = 200] */ textWrap.height = function(_) { return arguments.length ? (height = _, textWrap) : height; }; /** @memberof textWrap @desc If *value* is specified, sets the line height accessor to the specified function or number and returns this generator. If *value* is not specified, returns the current line height accessor, which is 1.1 times the [font size](#textWrap.fontSize) by default. @param {Function|Number} [*value*] */ textWrap.lineHeight = function(_) { return arguments.length ? (lineHeight = _, textWrap) : lineHeight; }; /** @memberof textWrap @desc If *value* is specified, sets the maximum number of lines allowed when wrapping. @param {Function|Number} [*value*] */ textWrap.maxLines = function(_) { return arguments.length ? (maxLines = _, textWrap) : maxLines; }; /** @memberof textWrap @desc If *value* is specified, sets the overflow to the specified boolean and returns this generator. If *value* is not specified, returns the current overflow value. @param {Boolean} [*value* = false] */ textWrap.overflow = function(_) { return arguments.length ? (overflow = _, textWrap) : overflow; }; /** @memberof textWrap @desc If *value* is specified, sets the word split function to the specified function and returns this generator. If *value* is not specified, returns the current word split function. @param {Function} [*value*] A function that, when passed a string, is expected to return that string split into an array of words to textWrap. The default split function splits strings on the following characters: `-`, `/`, `;`, `:`, `&` */ textWrap.split = function(_) { return arguments.length ? (split = _, textWrap) : split; }; /** @memberof textWrap @desc If *value* is specified, sets width limit to the specified value and returns this generator. If *value* is not specified, returns the current value. @param {Number} [*value* = 200] */ textWrap.width = function(_) { return arguments.length ? (width = _, textWrap) : width; }; return textWrap; } const lowercase = [ "a", "an", "and", "as", "at", "but", "by", "for", "from", "if", "in", "into", "near", "nor", "of", "on", "onto", "or", "per", "that", "the", "to", "with", "via", "vs", "vs." ]; const acronyms = [ "CEO", "CFO", "CNC", "COO", "CPU", "GDP", "HVAC", "ID", "IT", "R&D", "TV", "UI" ]; const uppercase = acronyms.reduce((arr, d)=>(arr.push(`${d}s`), arr), acronyms.map((d)=>d.toLowerCase())); /** @function titleCase @desc Capitalizes the first letter of each word in a phrase/sentence, accounting for words in English that should be kept lowercase such as "and" or "of", as well as acronym that should be kept uppercase such as "CEO" or "TVs". @param {String} str The string to apply the title case logic. */ function titleCase(str) { if (str === undefined) return ""; return textSplit(str).reduce((str, word, i)=>{ let formattedWord = word; const trimmedWord = word.toLowerCase().slice(0, -1); const exempt = uppercase.includes(trimmedWord) || lowercase.includes(trimmedWord) && i !== 0 && word.toLowerCase() !== trimmedWord; if (!exempt) formattedWord = word.charAt(0).toUpperCase() + word.slice(1); return str + formattedWord; }, ""); } exports.fontFamily = fontFamily; exports.fontFamilyStringify = fontFamilyStringify; exports.stringify = stringify; exports.strip = strip; exports.textSplit = textSplit; exports.textWrap = textWrap; exports.titleCase = titleCase; exports.trim = trim; exports.trimLeft = trimLeft; exports.trimRight = trimRight; })); //# sourceMappingURL=d3plus-text.js.map