Text.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import {
  2. isObject,
  3. assign,
  4. forEach,
  5. reduce
  6. } from 'min-dash';
  7. import {
  8. append as svgAppend,
  9. attr as svgAttr,
  10. create as svgCreate,
  11. remove as svgRemove
  12. } from 'tiny-svg';
  13. var DEFAULT_BOX_PADDING = 0;
  14. var DEFAULT_LABEL_SIZE = {
  15. width: 150,
  16. height: 50
  17. };
  18. function parseAlign(align) {
  19. var parts = align.split('-');
  20. return {
  21. horizontal: parts[0] || 'center',
  22. vertical: parts[1] || 'top'
  23. };
  24. }
  25. function parsePadding(padding) {
  26. if (isObject(padding)) {
  27. return assign({ top: 0, left: 0, right: 0, bottom: 0 }, padding);
  28. } else {
  29. return {
  30. top: padding,
  31. left: padding,
  32. right: padding,
  33. bottom: padding
  34. };
  35. }
  36. }
  37. function getTextBBox(text, fakeText) {
  38. fakeText.textContent = text;
  39. var textBBox;
  40. try {
  41. var bbox,
  42. emptyLine = text === '';
  43. // add dummy text, when line is empty to
  44. // determine correct height
  45. fakeText.textContent = emptyLine ? 'dummy' : text;
  46. textBBox = fakeText.getBBox();
  47. // take text rendering related horizontal
  48. // padding into account
  49. bbox = {
  50. width: textBBox.width + textBBox.x * 2,
  51. height: textBBox.height
  52. };
  53. if (emptyLine) {
  54. // correct width
  55. bbox.width = 0;
  56. }
  57. return bbox;
  58. } catch (e) {
  59. return { width: 0, height: 0 };
  60. }
  61. }
  62. /**
  63. * Layout the next line and return the layouted element.
  64. *
  65. * Alters the lines passed.
  66. *
  67. * @param {Array<String>} lines
  68. * @return {Object} the line descriptor, an object { width, height, text }
  69. */
  70. function layoutNext(lines, maxWidth, fakeText) {
  71. var originalLine = lines.shift(),
  72. fitLine = originalLine;
  73. var textBBox;
  74. for (;;) {
  75. textBBox = getTextBBox(fitLine, fakeText);
  76. textBBox.width = fitLine ? textBBox.width : 0;
  77. // try to fit
  78. if (fitLine === ' ' || fitLine === '' || textBBox.width < Math.round(maxWidth) || fitLine.length < 2) {
  79. return fit(lines, fitLine, originalLine, textBBox);
  80. }
  81. fitLine = shortenLine(fitLine, textBBox.width, maxWidth);
  82. }
  83. }
  84. function fit(lines, fitLine, originalLine, textBBox) {
  85. if (fitLine.length < originalLine.length) {
  86. var remainder = originalLine.slice(fitLine.length).trim();
  87. lines.unshift(remainder);
  88. }
  89. return {
  90. width: textBBox.width,
  91. height: textBBox.height,
  92. text: fitLine
  93. };
  94. }
  95. /**
  96. * Shortens a line based on spacing and hyphens.
  97. * Returns the shortened result on success.
  98. *
  99. * @param {String} line
  100. * @param {Number} maxLength the maximum characters of the string
  101. * @return {String} the shortened string
  102. */
  103. function semanticShorten(line, maxLength) {
  104. var parts = line.split(/(\s|-)/g),
  105. part,
  106. shortenedParts = [],
  107. length = 0;
  108. // try to shorten via spaces + hyphens
  109. if (parts.length > 1) {
  110. while ((part = parts.shift())) {
  111. if (part.length + length < maxLength) {
  112. shortenedParts.push(part);
  113. length += part.length;
  114. } else {
  115. // remove previous part, too if hyphen does not fit anymore
  116. if (part === '-') {
  117. shortenedParts.pop();
  118. }
  119. break;
  120. }
  121. }
  122. }
  123. return shortenedParts.join('');
  124. }
  125. function shortenLine(line, width, maxWidth) {
  126. var length = Math.max(line.length * (maxWidth / width), 1);
  127. // try to shorten semantically (i.e. based on spaces and hyphens)
  128. var shortenedLine = semanticShorten(line, length);
  129. if (!shortenedLine) {
  130. // force shorten by cutting the long word
  131. shortenedLine = line.slice(0, Math.max(Math.round(length - 1), 1));
  132. }
  133. return shortenedLine;
  134. }
  135. function getHelperSvg() {
  136. var helperSvg = document.getElementById('helper-svg');
  137. if (!helperSvg) {
  138. helperSvg = svgCreate('svg');
  139. svgAttr(helperSvg, {
  140. id: 'helper-svg',
  141. width: 0,
  142. height: 0,
  143. style: 'visibility: hidden; position: fixed'
  144. });
  145. document.body.appendChild(helperSvg);
  146. }
  147. return helperSvg;
  148. }
  149. /**
  150. * Creates a new label utility
  151. *
  152. * @param {Object} config
  153. * @param {Dimensions} config.size
  154. * @param {Number} config.padding
  155. * @param {Object} config.style
  156. * @param {String} config.align
  157. */
  158. export default function Text(config) {
  159. this._config = assign({}, {
  160. size: DEFAULT_LABEL_SIZE,
  161. padding: DEFAULT_BOX_PADDING,
  162. style: {},
  163. align: 'center-top'
  164. }, config || {});
  165. }
  166. /**
  167. * Returns the layouted text as an SVG element.
  168. *
  169. * @param {String} text
  170. * @param {Object} options
  171. *
  172. * @return {SVGElement}
  173. */
  174. Text.prototype.createText = function(text, options) {
  175. return this.layoutText(text, options).element;
  176. };
  177. /**
  178. * Returns a labels layouted dimensions.
  179. *
  180. * @param {String} text to layout
  181. * @param {Object} options
  182. *
  183. * @return {Dimensions}
  184. */
  185. Text.prototype.getDimensions = function(text, options) {
  186. return this.layoutText(text, options).dimensions;
  187. };
  188. /**
  189. * Creates and returns a label and its bounding box.
  190. *
  191. * @method Text#createText
  192. *
  193. * @param {String} text the text to render on the label
  194. * @param {Object} options
  195. * @param {String} options.align how to align in the bounding box.
  196. * Any of { 'center-middle', 'center-top' },
  197. * defaults to 'center-top'.
  198. * @param {String} options.style style to be applied to the text
  199. * @param {boolean} options.fitBox indicates if box will be recalculated to
  200. * fit text
  201. *
  202. * @return {Object} { element, dimensions }
  203. */
  204. Text.prototype.layoutText = function(text, options) {
  205. var box = assign({}, this._config.size, options.box),
  206. style = assign({}, this._config.style, options.style),
  207. align = parseAlign(options.align || this._config.align),
  208. padding = parsePadding(options.padding !== undefined ? options.padding : this._config.padding),
  209. fitBox = options.fitBox || false;
  210. var lineHeight = getLineHeight(style);
  211. var lines = text.split(/\r?\n/g),
  212. layouted = [];
  213. var maxWidth = box.width - padding.left - padding.right;
  214. // ensure correct rendering by attaching helper text node to invisible SVG
  215. var helperText = svgCreate('text');
  216. svgAttr(helperText, { x: 0, y: 0 });
  217. svgAttr(helperText, style);
  218. var helperSvg = getHelperSvg();
  219. svgAppend(helperSvg, helperText);
  220. while (lines.length) {
  221. layouted.push(layoutNext(lines, maxWidth, helperText));
  222. }
  223. if (align.vertical === 'middle') {
  224. padding.top = padding.bottom = 0;
  225. }
  226. var totalHeight = reduce(layouted, function(sum, line, idx) {
  227. return sum + (lineHeight || line.height);
  228. }, 0) + padding.top + padding.bottom;
  229. var maxLineWidth = reduce(layouted, function(sum, line, idx) {
  230. return line.width > sum ? line.width : sum;
  231. }, 0);
  232. // the y position of the next line
  233. var y = padding.top;
  234. if (align.vertical === 'middle') {
  235. y += (box.height - totalHeight) / 2;
  236. }
  237. // magic number initial offset
  238. y -= (lineHeight || layouted[0].height) / 4;
  239. var textElement = svgCreate('text');
  240. svgAttr(textElement, style);
  241. // layout each line taking into account that parent
  242. // shape might resize to fit text size
  243. forEach(layouted, function(line) {
  244. var x;
  245. y += (lineHeight || line.height);
  246. switch (align.horizontal) {
  247. case 'left':
  248. x = padding.left;
  249. break;
  250. case 'right':
  251. x = ((fitBox ? maxLineWidth : maxWidth)
  252. - padding.right - line.width);
  253. break;
  254. default:
  255. // aka center
  256. x = Math.max((((fitBox ? maxLineWidth : maxWidth)
  257. - line.width) / 2 + padding.left), 0);
  258. }
  259. var tspan = svgCreate('tspan');
  260. svgAttr(tspan, { x: x, y: y });
  261. tspan.textContent = line.text;
  262. svgAppend(textElement, tspan);
  263. });
  264. svgRemove(helperText);
  265. var dimensions = {
  266. width: maxLineWidth,
  267. height: totalHeight
  268. };
  269. return {
  270. dimensions: dimensions,
  271. element: textElement
  272. };
  273. };
  274. function getLineHeight(style) {
  275. if ('fontSize' in style && 'lineHeight' in style) {
  276. return style.lineHeight * parseInt(style.fontSize, 10);
  277. }
  278. }