Text.js 8.2 KB

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