@d3plus/text
Version:
A smart SVG text box with line wrapping and automatic font size scaling.
482 lines (450 loc) • 15.4 kB
JavaScript
/*
@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, '&').replace(/</g, '<').replace(/>/g, '>');
}
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