text.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. /* eslint-disable no-control-regex */
  2. import { StringExt } from '../string';
  3. import { Text } from '../text';
  4. import { attr } from './attr';
  5. import { Vector } from '../vector';
  6. import { createSvgElement, empty } from './elem';
  7. function createTextPathNode(attrs, elem) {
  8. const vel = Vector.create(elem);
  9. const textPath = Vector.create('textPath');
  10. const d = attrs.d;
  11. if (d && attrs['xlink:href'] === undefined) {
  12. const path = Vector.create('path').attr('d', d).appendTo(vel.defs());
  13. textPath.attr('xlink:href', `#${path.id}`);
  14. }
  15. if (typeof attrs === 'object') {
  16. textPath.attr(attrs);
  17. }
  18. return textPath.node;
  19. }
  20. function annotateTextLine(lineNode, lineAnnotations, options) {
  21. const eol = options.eol;
  22. const baseSize = options.baseSize;
  23. const lineHeight = options.lineHeight;
  24. let maxFontSize = 0;
  25. let tspanNode;
  26. const fontMetrics = {};
  27. const lastJ = lineAnnotations.length - 1;
  28. for (let j = 0; j <= lastJ; j += 1) {
  29. let annotation = lineAnnotations[j];
  30. let fontSize = null;
  31. if (typeof annotation === 'object') {
  32. const annotationAttrs = annotation.attrs;
  33. const vTSpan = Vector.create('tspan', annotationAttrs);
  34. tspanNode = vTSpan.node;
  35. let t = annotation.t;
  36. if (eol && j === lastJ) {
  37. t += eol;
  38. }
  39. tspanNode.textContent = t;
  40. // Per annotation className
  41. const annotationClass = annotationAttrs.class;
  42. if (annotationClass) {
  43. vTSpan.addClass(annotationClass);
  44. }
  45. // set the list of indices of all the applied annotations
  46. // in the `annotations` attribute. This list is a comma
  47. // separated list of indices.
  48. if (options.includeAnnotationIndices) {
  49. vTSpan.attr('annotations', annotation.annotations.join(','));
  50. }
  51. // Check for max font size
  52. fontSize = parseFloat(annotationAttrs['font-size']);
  53. if (fontSize === undefined)
  54. fontSize = baseSize;
  55. if (fontSize && fontSize > maxFontSize)
  56. maxFontSize = fontSize;
  57. }
  58. else {
  59. if (eol && j === lastJ) {
  60. annotation += eol;
  61. }
  62. tspanNode = document.createTextNode(annotation || ' ');
  63. if (baseSize && baseSize > maxFontSize) {
  64. maxFontSize = baseSize;
  65. }
  66. }
  67. lineNode.appendChild(tspanNode);
  68. }
  69. if (maxFontSize) {
  70. fontMetrics.maxFontSize = maxFontSize;
  71. }
  72. if (lineHeight) {
  73. fontMetrics.lineHeight = lineHeight;
  74. }
  75. else if (maxFontSize) {
  76. fontMetrics.lineHeight = maxFontSize * 1.2;
  77. }
  78. return fontMetrics;
  79. }
  80. const emRegex = /em$/;
  81. function emToPx(em, fontSize) {
  82. const numerical = parseFloat(em);
  83. if (emRegex.test(em)) {
  84. return numerical * fontSize;
  85. }
  86. return numerical;
  87. }
  88. function calculateDY(alignment, linesMetrics, baseSizePx, lineHeight) {
  89. if (!Array.isArray(linesMetrics)) {
  90. return 0;
  91. }
  92. const n = linesMetrics.length;
  93. if (!n)
  94. return 0;
  95. let lineMetrics = linesMetrics[0];
  96. const flMaxFont = emToPx(lineMetrics.maxFontSize, baseSizePx) || baseSizePx;
  97. let rLineHeights = 0;
  98. const lineHeightPx = emToPx(lineHeight, baseSizePx);
  99. for (let i = 1; i < n; i += 1) {
  100. lineMetrics = linesMetrics[i];
  101. const iLineHeight = emToPx(lineMetrics.lineHeight, baseSizePx) || lineHeightPx;
  102. rLineHeights += iLineHeight;
  103. }
  104. const llMaxFont = emToPx(lineMetrics.maxFontSize, baseSizePx) || baseSizePx;
  105. let dy;
  106. switch (alignment) {
  107. case 'middle':
  108. dy = flMaxFont / 2 - 0.15 * llMaxFont - rLineHeights / 2;
  109. break;
  110. case 'bottom':
  111. dy = -(0.25 * llMaxFont) - rLineHeights;
  112. break;
  113. case 'top':
  114. default:
  115. dy = 0.8 * flMaxFont;
  116. break;
  117. }
  118. return dy;
  119. }
  120. export function text(elem, content, options = {}) {
  121. content = Text.sanitize(content); // eslint-disable-line
  122. const eol = options.eol;
  123. let textPath = options.textPath;
  124. const verticalAnchor = options.textVerticalAnchor;
  125. const namedVerticalAnchor = verticalAnchor === 'middle' ||
  126. verticalAnchor === 'bottom' ||
  127. verticalAnchor === 'top';
  128. // Horizontal shift applied to all the lines but the first.
  129. let x = options.x;
  130. if (x === undefined) {
  131. x = elem.getAttribute('x') || 0;
  132. }
  133. // Annotations
  134. const iai = options.includeAnnotationIndices;
  135. let annotations = options.annotations;
  136. if (annotations && !Array.isArray(annotations)) {
  137. annotations = [annotations];
  138. }
  139. // Shift all the <tspan> but first by one line (`1em`)
  140. const defaultLineHeight = options.lineHeight;
  141. const autoLineHeight = defaultLineHeight === 'auto';
  142. const lineHeight = autoLineHeight ? '1.5em' : defaultLineHeight || '1em';
  143. let needEmpty = true;
  144. const childNodes = elem.childNodes;
  145. if (childNodes.length === 1) {
  146. const node = childNodes[0];
  147. if (node && node.tagName.toUpperCase() === 'TITLE') {
  148. needEmpty = false;
  149. }
  150. }
  151. if (needEmpty) {
  152. empty(elem);
  153. }
  154. attr(elem, {
  155. // Preserve spaces, do not consecutive spaces to get collapsed to one.
  156. 'xml:space': 'preserve',
  157. // An empty text gets rendered into the DOM in webkit-based browsers.
  158. // In order to unify this behaviour across all browsers
  159. // we rather hide the text element when it's empty.
  160. display: content || options.displayEmpty ? null : 'none',
  161. });
  162. // Set default font-size if none
  163. const strFontSize = attr(elem, 'font-size');
  164. let fontSize = parseFloat(strFontSize);
  165. if (!fontSize) {
  166. fontSize = 16;
  167. if ((namedVerticalAnchor || annotations) && !strFontSize) {
  168. attr(elem, 'font-size', `${fontSize}`);
  169. }
  170. }
  171. let containerNode;
  172. if (textPath) {
  173. // Now all the `<tspan>`s will be inside the `<textPath>`.
  174. if (typeof textPath === 'string') {
  175. textPath = { d: textPath };
  176. }
  177. containerNode = createTextPathNode(textPath, elem);
  178. }
  179. else {
  180. containerNode = document.createDocumentFragment();
  181. }
  182. let dy;
  183. let offset = 0;
  184. let annotatedY;
  185. const lines = content.split('\n');
  186. const linesMetrics = [];
  187. const lastI = lines.length - 1;
  188. for (let i = 0; i <= lastI; i += 1) {
  189. dy = lineHeight;
  190. let lineClassName = 'v-line';
  191. const lineNode = createSvgElement('tspan');
  192. let lineMetrics;
  193. let line = lines[i];
  194. if (line) {
  195. if (annotations) {
  196. // Find the *compacted* annotations for this line.
  197. const lineAnnotations = Text.annotate(line, annotations, {
  198. offset: -offset,
  199. includeAnnotationIndices: iai,
  200. });
  201. lineMetrics = annotateTextLine(lineNode, lineAnnotations, {
  202. eol: i !== lastI && eol,
  203. baseSize: fontSize,
  204. lineHeight: autoLineHeight ? null : lineHeight,
  205. includeAnnotationIndices: iai,
  206. });
  207. // Get the line height based on the biggest font size
  208. // in the annotations for this line.
  209. const iLineHeight = lineMetrics.lineHeight;
  210. if (iLineHeight && autoLineHeight && i !== 0) {
  211. dy = iLineHeight;
  212. }
  213. if (i === 0) {
  214. annotatedY = lineMetrics.maxFontSize * 0.8;
  215. }
  216. }
  217. else {
  218. if (eol && i !== lastI) {
  219. line += eol;
  220. }
  221. lineNode.textContent = line;
  222. }
  223. }
  224. else {
  225. // Make sure the textContent is never empty. If it is, add a dummy
  226. // character and make it invisible, making the following lines correctly
  227. // relatively positioned. `dy=1em` won't work with empty lines otherwise.
  228. lineNode.textContent = '-';
  229. lineClassName += ' v-empty-line';
  230. const lineNodeStyle = lineNode.style;
  231. lineNodeStyle.fillOpacity = 0;
  232. lineNodeStyle.strokeOpacity = 0;
  233. if (annotations) {
  234. lineMetrics = {};
  235. }
  236. }
  237. if (lineMetrics) {
  238. linesMetrics.push(lineMetrics);
  239. }
  240. if (i > 0) {
  241. lineNode.setAttribute('dy', dy);
  242. }
  243. // Firefox requires 'x' to be set on the first line
  244. if (i > 0 || textPath) {
  245. lineNode.setAttribute('x', x);
  246. }
  247. lineNode.className.baseVal = lineClassName;
  248. containerNode.appendChild(lineNode);
  249. offset += line.length + 1; // + 1 = newline character.
  250. }
  251. // Y Alignment calculation
  252. if (namedVerticalAnchor) {
  253. if (annotations) {
  254. dy = calculateDY(verticalAnchor, linesMetrics, fontSize, lineHeight);
  255. }
  256. else if (verticalAnchor === 'top') {
  257. // A shortcut for top alignment. It does not depend on font-size nor line-height
  258. dy = '0.8em';
  259. }
  260. else {
  261. let rh; // remaining height
  262. if (lastI > 0) {
  263. rh = parseFloat(lineHeight) || 1;
  264. rh *= lastI;
  265. if (!emRegex.test(lineHeight))
  266. rh /= fontSize;
  267. }
  268. else {
  269. // Single-line text
  270. rh = 0;
  271. }
  272. switch (verticalAnchor) {
  273. case 'middle':
  274. dy = `${0.3 - rh / 2}em`;
  275. break;
  276. case 'bottom':
  277. dy = `${-rh - 0.3}em`;
  278. break;
  279. default:
  280. break;
  281. }
  282. }
  283. }
  284. else if (verticalAnchor === 0) {
  285. dy = '0em';
  286. }
  287. else if (verticalAnchor) {
  288. dy = verticalAnchor;
  289. }
  290. else {
  291. // No vertical anchor is defined
  292. dy = 0;
  293. // Backwards compatibility - we change the `y` attribute instead of `dy`.
  294. if (elem.getAttribute('y') == null) {
  295. elem.setAttribute('y', `${annotatedY || '0.8em'}`);
  296. }
  297. }
  298. const firstLine = containerNode.firstChild;
  299. firstLine.setAttribute('dy', dy);
  300. elem.appendChild(containerNode);
  301. }
  302. export function measureText(text, styles = {}) {
  303. const canvasContext = document.createElement('canvas').getContext('2d');
  304. if (!text) {
  305. return { width: 0 };
  306. }
  307. const font = [];
  308. const fontSize = styles['font-size']
  309. ? `${parseFloat(styles['font-size'])}px`
  310. : '14px';
  311. font.push(styles['font-style'] || 'normal');
  312. font.push(styles['font-variant'] || 'normal');
  313. font.push(styles['font-weight'] || 400);
  314. font.push(fontSize);
  315. font.push(styles['font-family'] || 'sans-serif');
  316. canvasContext.font = font.join(' ');
  317. return canvasContext.measureText(text);
  318. }
  319. export function splitTextByLength(text, splitWidth, totalWidth, style = {}) {
  320. if (splitWidth >= totalWidth) {
  321. return [text, ''];
  322. }
  323. const length = text.length;
  324. const caches = {};
  325. let index = Math.round((splitWidth / totalWidth) * length - 1);
  326. if (index < 0) {
  327. index = 0;
  328. }
  329. // eslint-disable-next-line
  330. while (index >= 0 && index < length) {
  331. const frontText = text.slice(0, index);
  332. const frontWidth = caches[frontText] || measureText(frontText, style).width;
  333. const behindText = text.slice(0, index + 1);
  334. const behindWidth = caches[behindText] || measureText(behindText, style).width;
  335. caches[frontText] = frontWidth;
  336. caches[behindText] = behindWidth;
  337. if (frontWidth > splitWidth) {
  338. index -= 1;
  339. }
  340. else if (behindWidth <= splitWidth) {
  341. index += 1;
  342. }
  343. else {
  344. break;
  345. }
  346. }
  347. return [text.slice(0, index), text.slice(index)];
  348. }
  349. export function breakText(text, size, styles = {}, options = {}) {
  350. const width = size.width;
  351. const height = size.height;
  352. const eol = options.eol || '\n';
  353. const fontSize = styles.fontSize || 14;
  354. const lineHeight = styles.lineHeight
  355. ? parseFloat(styles.lineHeight)
  356. : Math.ceil(fontSize * 1.4);
  357. const maxLines = Math.floor(height / lineHeight);
  358. if (text.indexOf(eol) > -1) {
  359. const delimiter = StringExt.uuid();
  360. const splitText = [];
  361. text.split(eol).map((line) => {
  362. const part = breakText(line, Object.assign(Object.assign({}, size), { height: Number.MAX_SAFE_INTEGER }), styles, Object.assign(Object.assign({}, options), { eol: delimiter }));
  363. if (part) {
  364. splitText.push(...part.split(delimiter));
  365. }
  366. });
  367. return splitText.slice(0, maxLines).join(eol);
  368. }
  369. const { width: textWidth } = measureText(text, styles);
  370. if (textWidth < width) {
  371. return text;
  372. }
  373. const lines = [];
  374. let remainText = text;
  375. let remainWidth = textWidth;
  376. let ellipsis = options.ellipsis;
  377. let ellipsisWidth = 0;
  378. if (ellipsis) {
  379. if (typeof ellipsis !== 'string') {
  380. ellipsis = '\u2026';
  381. }
  382. ellipsisWidth = measureText(ellipsis, styles).width;
  383. }
  384. for (let i = 0; i < maxLines; i += 1) {
  385. if (remainWidth > width) {
  386. const isLast = i === maxLines - 1;
  387. if (isLast) {
  388. const [front] = splitTextByLength(remainText, width - ellipsisWidth, remainWidth, styles);
  389. lines.push(ellipsis ? `${front}${ellipsis}` : front);
  390. }
  391. else {
  392. const [front, behind] = splitTextByLength(remainText, width, remainWidth, styles);
  393. lines.push(front);
  394. remainText = behind;
  395. remainWidth = measureText(remainText, styles).width;
  396. }
  397. }
  398. else {
  399. lines.push(remainText);
  400. break;
  401. }
  402. }
  403. return lines.join(eol);
  404. }
  405. //# sourceMappingURL=text.js.map