Source: core/text/TextMetrics.js

core/text/TextMetrics.js

  1. /**
  2. * The TextMetrics object represents the measurement of a block of text with a specified style.
  3. *
  4. * ```js
  5. * let style = new PIXI.TextStyle({fontFamily : 'Arial', fontSize: 24, fill : 0xff1010, align : 'center'})
  6. * let textMetrics = PIXI.TextMetrics.measureText('Your text', style)
  7. * ```
  8. *
  9. * @class
  10. * @memberOf PIXI
  11. */
  12. //引入小程序补丁
  13. import { documentAlias } from '@ali/pixi-miniprogram-adapter';
  14. export default class TextMetrics
  15. {
  16. /**
  17. * @param {string} text - the text that was measured
  18. * @param {PIXI.TextStyle} style - the style that was measured
  19. * @param {number} width - the measured width of the text
  20. * @param {number} height - the measured height of the text
  21. * @param {array} lines - an array of the lines of text broken by new lines and wrapping if specified in style
  22. * @param {array} lineWidths - an array of the line widths for each line matched to `lines`
  23. * @param {number} lineHeight - the measured line height for this style
  24. * @param {number} maxLineWidth - the maximum line width for all measured lines
  25. * @param {Object} fontProperties - the font properties object from TextMetrics.measureFont
  26. */
  27. constructor(text, style, width, height, lines, lineWidths, lineHeight, maxLineWidth, fontProperties)
  28. {
  29. this.text = text;
  30. this.style = style;
  31. this.width = width;
  32. this.height = height;
  33. this.lines = lines;
  34. this.lineWidths = lineWidths;
  35. this.lineHeight = lineHeight;
  36. this.maxLineWidth = maxLineWidth;
  37. this.fontProperties = fontProperties;
  38. }
  39. /**
  40. * Measures the supplied string of text and returns a Rectangle.
  41. *
  42. * @param {string} text - the text to measure.
  43. * @param {PIXI.TextStyle} style - the text style to use for measuring
  44. * @param {boolean} [wordWrap] - optional override for if word-wrap should be applied to the text.
  45. * @param {HTMLCanvasElement} [canvas] - optional specification of the canvas to use for measuring.
  46. * @return {PIXI.TextMetrics} measured width and height of the text.
  47. */
  48. static measureText(text, style, wordWrap, canvas = TextMetrics._canvas)
  49. {
  50. wordWrap = (wordWrap === undefined || wordWrap === null) ? style.wordWrap : wordWrap;
  51. const font = style.toFontString();
  52. const fontProperties = TextMetrics.measureFont(font);
  53. const context = canvas.getContext('2d');
  54. context.font = font;
  55. const outputText = wordWrap ? TextMetrics.wordWrap(text, style, canvas) : text;
  56. const lines = outputText.split(/(?:\r\n|\r|\n)/);
  57. const lineWidths = new Array(lines.length);
  58. let maxLineWidth = 0;
  59. for (let i = 0; i < lines.length; i++)
  60. {
  61. const lineWidth = context.measureText(lines[i]).width + ((lines[i].length - 1) * style.letterSpacing);
  62. lineWidths[i] = lineWidth;
  63. maxLineWidth = Math.max(maxLineWidth, lineWidth);
  64. }
  65. let width = maxLineWidth + style.strokeThickness;
  66. if (style.dropShadow)
  67. {
  68. width += style.dropShadowDistance;
  69. }
  70. const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness;
  71. let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness)
  72. + ((lines.length - 1) * (lineHeight + style.leading));
  73. if (style.dropShadow)
  74. {
  75. height += style.dropShadowDistance;
  76. }
  77. return new TextMetrics(
  78. text,
  79. style,
  80. width,
  81. height,
  82. lines,
  83. lineWidths,
  84. lineHeight + style.leading,
  85. maxLineWidth,
  86. fontProperties
  87. );
  88. }
  89. /**
  90. * Applies newlines to a string to have it optimally fit into the horizontal
  91. * bounds set by the Text object's wordWrapWidth property.
  92. *
  93. * @private
  94. * @param {string} text - String to apply word wrapping to
  95. * @param {PIXI.TextStyle} style - the style to use when wrapping
  96. * @param {HTMLCanvasElement} [canvas] - optional specification of the canvas to use for measuring.
  97. * @return {string} New string with new lines applied where required
  98. */
  99. static wordWrap(text, style, canvas = TextMetrics._canvas)
  100. {
  101. const context = canvas.getContext('2d');
  102. let width = 0;
  103. let line = '';
  104. let lines = '';
  105. const cache = {};
  106. const { letterSpacing, whiteSpace } = style;
  107. // How to handle whitespaces
  108. const collapseSpaces = TextMetrics.collapseSpaces(whiteSpace);
  109. const collapseNewlines = TextMetrics.collapseNewlines(whiteSpace);
  110. // whether or not spaces may be added to the beginning of lines
  111. let canPrependSpaces = !collapseSpaces;
  112. // There is letterSpacing after every char except the last one
  113. // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!
  114. // so for convenience the above needs to be compared to width + 1 extra letterSpace
  115. // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!_
  116. // ________________________________________________
  117. // And then the final space is simply no appended to each line
  118. const wordWrapWidth = style.wordWrapWidth + letterSpacing;
  119. // break text into words, spaces and newline chars
  120. const tokens = TextMetrics.tokenize(text);
  121. for (let i = 0; i < tokens.length; i++)
  122. {
  123. // get the word, space or newlineChar
  124. let token = tokens[i];
  125. // if word is a new line
  126. if (TextMetrics.isNewline(token))
  127. {
  128. // keep the new line
  129. if (!collapseNewlines)
  130. {
  131. lines += TextMetrics.addLine(line);
  132. canPrependSpaces = !collapseSpaces;
  133. line = '';
  134. width = 0;
  135. continue;
  136. }
  137. // if we should collapse new lines
  138. // we simply convert it into a space
  139. token = ' ';
  140. }
  141. // if we should collapse repeated whitespaces
  142. if (collapseSpaces)
  143. {
  144. // check both this and the last tokens for spaces
  145. const currIsBreakingSpace = TextMetrics.isBreakingSpace(token);
  146. const lastIsBreakingSpace = TextMetrics.isBreakingSpace(line[line.length - 1]);
  147. if (currIsBreakingSpace && lastIsBreakingSpace)
  148. {
  149. continue;
  150. }
  151. }
  152. // get word width from cache if possible
  153. const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context);
  154. // word is longer than desired bounds
  155. if (tokenWidth > wordWrapWidth)
  156. {
  157. // if we are not already at the beginning of a line
  158. if (line !== '')
  159. {
  160. // start newlines for overflow words
  161. lines += TextMetrics.addLine(line);
  162. line = '';
  163. width = 0;
  164. }
  165. // break large word over multiple lines
  166. if (TextMetrics.canBreakWords(token, style.breakWords))
  167. {
  168. // break word into characters
  169. const characters = token.split('');
  170. // loop the characters
  171. for (let j = 0; j < characters.length; j++)
  172. {
  173. let char = characters[j];
  174. let k = 1;
  175. // we are not at the end of the token
  176. while (characters[j + k])
  177. {
  178. const nextChar = characters[j + k];
  179. const lastChar = char[char.length - 1];
  180. // should not split chars
  181. if (!TextMetrics.canBreakChars(lastChar, nextChar, token, j, style.breakWords))
  182. {
  183. // combine chars & move forward one
  184. char += nextChar;
  185. }
  186. else
  187. {
  188. break;
  189. }
  190. k++;
  191. }
  192. j += char.length - 1;
  193. const characterWidth = TextMetrics.getFromCache(char, letterSpacing, cache, context);
  194. if (characterWidth + width > wordWrapWidth)
  195. {
  196. lines += TextMetrics.addLine(line);
  197. canPrependSpaces = false;
  198. line = '';
  199. width = 0;
  200. }
  201. line += char;
  202. width += characterWidth;
  203. }
  204. }
  205. // run word out of the bounds
  206. else
  207. {
  208. // if there are words in this line already
  209. // finish that line and start a new one
  210. if (line.length > 0)
  211. {
  212. lines += TextMetrics.addLine(line);
  213. line = '';
  214. width = 0;
  215. }
  216. const isLastToken = i === tokens.length - 1;
  217. // give it its own line if it's not the end
  218. lines += TextMetrics.addLine(token, !isLastToken);
  219. canPrependSpaces = false;
  220. line = '';
  221. width = 0;
  222. }
  223. }
  224. // word could fit
  225. else
  226. {
  227. // word won't fit because of existing words
  228. // start a new line
  229. if (tokenWidth + width > wordWrapWidth)
  230. {
  231. // if its a space we don't want it
  232. canPrependSpaces = false;
  233. // add a new line
  234. lines += TextMetrics.addLine(line);
  235. // start a new line
  236. line = '';
  237. width = 0;
  238. }
  239. // don't add spaces to the beginning of lines
  240. if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces)
  241. {
  242. // add the word to the current line
  243. line += token;
  244. // update width counter
  245. width += tokenWidth;
  246. }
  247. }
  248. }
  249. lines += TextMetrics.addLine(line, false);
  250. return lines;
  251. }
  252. /**
  253. * Convienience function for logging each line added during the wordWrap
  254. * method
  255. *
  256. * @private
  257. * @param {string} line - The line of text to add
  258. * @param {boolean} newLine - Add new line character to end
  259. * @return {string} A formatted line
  260. */
  261. static addLine(line, newLine = true)
  262. {
  263. line = TextMetrics.trimRight(line);
  264. line = (newLine) ? `${line}\n` : line;
  265. return line;
  266. }
  267. /**
  268. * Gets & sets the widths of calculated characters in a cache object
  269. *
  270. * @private
  271. * @param {string} key The key
  272. * @param {number} letterSpacing The letter spacing
  273. * @param {object} cache The cache
  274. * @param {CanvasRenderingContext2D} context The canvas context
  275. * @return {number} The from cache.
  276. */
  277. static getFromCache(key, letterSpacing, cache, context)
  278. {
  279. let width = cache[key];
  280. if (width === undefined)
  281. {
  282. const spacing = ((key.length) * letterSpacing);
  283. width = context.measureText(key).width + spacing;
  284. cache[key] = width;
  285. }
  286. return width;
  287. }
  288. /**
  289. * Determines whether we should collapse breaking spaces
  290. *
  291. * @private
  292. * @param {string} whiteSpace The TextStyle property whiteSpace
  293. * @return {boolean} should collapse
  294. */
  295. static collapseSpaces(whiteSpace)
  296. {
  297. return (whiteSpace === 'normal' || whiteSpace === 'pre-line');
  298. }
  299. /**
  300. * Determines whether we should collapse newLine chars
  301. *
  302. * @private
  303. * @param {string} whiteSpace The white space
  304. * @return {boolean} should collapse
  305. */
  306. static collapseNewlines(whiteSpace)
  307. {
  308. return (whiteSpace === 'normal');
  309. }
  310. /**
  311. * trims breaking whitespaces from string
  312. *
  313. * @private
  314. * @param {string} text The text
  315. * @return {string} trimmed string
  316. */
  317. static trimRight(text)
  318. {
  319. if (typeof text !== 'string')
  320. {
  321. return '';
  322. }
  323. for (let i = text.length - 1; i >= 0; i--)
  324. {
  325. const char = text[i];
  326. if (!TextMetrics.isBreakingSpace(char))
  327. {
  328. break;
  329. }
  330. text = text.slice(0, -1);
  331. }
  332. return text;
  333. }
  334. /**
  335. * Determines if char is a newline.
  336. *
  337. * @private
  338. * @param {string} char The character
  339. * @return {boolean} True if newline, False otherwise.
  340. */
  341. static isNewline(char)
  342. {
  343. if (typeof char !== 'string')
  344. {
  345. return false;
  346. }
  347. return (TextMetrics._newlines.indexOf(char.charCodeAt(0)) >= 0);
  348. }
  349. /**
  350. * Determines if char is a breaking whitespace.
  351. *
  352. * @private
  353. * @param {string} char The character
  354. * @return {boolean} True if whitespace, False otherwise.
  355. */
  356. static isBreakingSpace(char)
  357. {
  358. if (typeof char !== 'string')
  359. {
  360. return false;
  361. }
  362. return (TextMetrics._breakingSpaces.indexOf(char.charCodeAt(0)) >= 0);
  363. }
  364. /**
  365. * Splits a string into words, breaking-spaces and newLine characters
  366. *
  367. * @private
  368. * @param {string} text The text
  369. * @return {array} A tokenized array
  370. */
  371. static tokenize(text)
  372. {
  373. const tokens = [];
  374. let token = '';
  375. if (typeof text !== 'string')
  376. {
  377. return tokens;
  378. }
  379. for (let i = 0; i < text.length; i++)
  380. {
  381. const char = text[i];
  382. if (TextMetrics.isBreakingSpace(char) || TextMetrics.isNewline(char))
  383. {
  384. if (token !== '')
  385. {
  386. tokens.push(token);
  387. token = '';
  388. }
  389. tokens.push(char);
  390. continue;
  391. }
  392. token += char;
  393. }
  394. if (token !== '')
  395. {
  396. tokens.push(token);
  397. }
  398. return tokens;
  399. }
  400. /**
  401. * This method exists to be easily overridden
  402. * It allows one to customise which words should break
  403. * Examples are if the token is CJK or numbers.
  404. * It must return a boolean.
  405. *
  406. * @private
  407. * @param {string} token The token
  408. * @param {boolean} breakWords The style attr break words
  409. * @return {boolean} whether to break word or not
  410. */
  411. static canBreakWords(token, breakWords)
  412. {
  413. return breakWords;
  414. }
  415. /**
  416. * This method exists to be easily overridden
  417. * It allows one to determine whether a pair of characters
  418. * should be broken by newlines
  419. * For example certain characters in CJK langs or numbers.
  420. * It must return a boolean.
  421. *
  422. * @private
  423. * @param {string} char The character
  424. * @param {string} nextChar The next character
  425. * @param {string} token The token/word the characters are from
  426. * @param {number} index The index in the token of the char
  427. * @param {boolean} breakWords The style attr break words
  428. * @return {boolean} whether to break word or not
  429. */
  430. static canBreakChars(char, nextChar, token, index, breakWords) // eslint-disable-line no-unused-vars
  431. {
  432. return true;
  433. }
  434. /**
  435. * Calculates the ascent, descent and fontSize of a given font-style
  436. *
  437. * @static
  438. * @param {string} font - String representing the style of the font
  439. * @return {PIXI.TextMetrics~FontMetrics} Font properties object
  440. */
  441. static measureFont(font)
  442. {
  443. // as this method is used for preparing assets, don't recalculate things if we don't need to
  444. if (TextMetrics._fonts[font])
  445. {
  446. return TextMetrics._fonts[font];
  447. }
  448. const properties = {};
  449. const canvas = TextMetrics._canvas;
  450. const context = TextMetrics._context;
  451. context.font = font;
  452. const metricsString = TextMetrics.METRICS_STRING + TextMetrics.BASELINE_SYMBOL;
  453. const width = Math.ceil(context.measureText(metricsString).width);
  454. let baseline = Math.ceil(context.measureText(TextMetrics.BASELINE_SYMBOL).width);
  455. const height = 2 * baseline;
  456. baseline = baseline * TextMetrics.BASELINE_MULTIPLIER | 0;
  457. canvas.width = width;
  458. canvas.height = height;
  459. context.fillStyle = '#f00';
  460. context.fillRect(0, 0, width, height);
  461. context.font = font;
  462. context.textBaseline = 'alphabetic';
  463. context.fillStyle = '#000';
  464. context.fillText(metricsString, 0, baseline);
  465. const imagedata = context.getImageData(0, 0, width, height).data;
  466. const pixels = imagedata.length;
  467. const line = width * 4;
  468. let i = 0;
  469. let idx = 0;
  470. let stop = false;
  471. // ascent. scan from top to bottom until we find a non red pixel
  472. for (i = 0; i < baseline; ++i)
  473. {
  474. for (let j = 0; j < line; j += 4)
  475. {
  476. // !!! 小程序canvas中 getImageData 返回的arraybuffer中 a通道的上限是254 故不能用255判断,而选择254判断
  477. // if (imagedata[idx + j] !== 255)
  478. if (imagedata[idx + j] < 254)
  479. {
  480. stop = true;
  481. break;
  482. }
  483. }
  484. if (!stop)
  485. {
  486. idx += line;
  487. }
  488. else
  489. {
  490. break;
  491. }
  492. }
  493. properties.ascent = baseline - i;
  494. idx = pixels - line;
  495. stop = false;
  496. // descent. scan from bottom to top until we find a non red pixel
  497. for (i = height; i > baseline; --i)
  498. {
  499. for (let j = 0; j < line; j += 4)
  500. {
  501. if (imagedata[idx + j] !== 255)
  502. {
  503. stop = true;
  504. break;
  505. }
  506. }
  507. if (!stop)
  508. {
  509. idx -= line;
  510. }
  511. else
  512. {
  513. break;
  514. }
  515. }
  516. properties.descent = i - baseline;
  517. properties.fontSize = properties.ascent + properties.descent;
  518. TextMetrics._fonts[font] = properties;
  519. return properties;
  520. }
  521. /**
  522. * Clear font metrics in metrics cache.
  523. *
  524. * @static
  525. * @param {string} [font] - font name. If font name not set then clear cache for all fonts.
  526. */
  527. static clearMetrics(font = '')
  528. {
  529. if (font)
  530. {
  531. delete TextMetrics._fonts[font];
  532. }
  533. else
  534. {
  535. TextMetrics._fonts = {};
  536. }
  537. }
  538. }
  539. /**
  540. * Internal return object for {@link PIXI.TextMetrics.measureFont `TextMetrics.measureFont`}.
  541. * @class FontMetrics
  542. * @memberof PIXI.TextMetrics~
  543. * @property {number} ascent - The ascent distance
  544. * @property {number} descent - The descent distance
  545. * @property {number} fontSize - Font size from ascent to descent
  546. */
  547. const canvas = documentAlias.createElement('canvas');
  548. canvas.width = canvas.height = 10;
  549. /**
  550. * Cached canvas element for measuring text
  551. * @memberof PIXI.TextMetrics
  552. * @type {HTMLCanvasElement}
  553. * @private
  554. */
  555. TextMetrics._canvas = canvas;
  556. /**
  557. * Cache for context to use.
  558. * @memberof PIXI.TextMetrics
  559. * @type {CanvasRenderingContext2D}
  560. * @private
  561. */
  562. TextMetrics._context = canvas.getContext('2d');
  563. /**
  564. * Cache of PIXI.TextMetrics~FontMetrics objects.
  565. * @memberof PIXI.TextMetrics
  566. * @type {Object}
  567. * @private
  568. */
  569. TextMetrics._fonts = {};
  570. /**
  571. * String used for calculate font metrics.
  572. * @static
  573. * @memberof PIXI.TextMetrics
  574. * @name METRICS_STRING
  575. * @type {string}
  576. * @default |Éq
  577. */
  578. TextMetrics.METRICS_STRING = '|Éq';
  579. /**
  580. * Baseline symbol for calculate font metrics.
  581. * @static
  582. * @memberof PIXI.TextMetrics
  583. * @name BASELINE_SYMBOL
  584. * @type {string}
  585. * @default M
  586. */
  587. TextMetrics.BASELINE_SYMBOL = 'M';
  588. /**
  589. * Baseline multiplier for calculate font metrics.
  590. * @static
  591. * @memberof PIXI.TextMetrics
  592. * @name BASELINE_MULTIPLIER
  593. * @type {number}
  594. * @default 1.4
  595. */
  596. TextMetrics.BASELINE_MULTIPLIER = 1.4;
  597. /**
  598. * Cache of new line chars.
  599. * @memberof PIXI.TextMetrics
  600. * @type {number[]}
  601. * @private
  602. */
  603. TextMetrics._newlines = [
  604. 0x000A, // line feed
  605. 0x000D, // carriage return
  606. ];
  607. /**
  608. * Cache of breaking spaces.
  609. * @memberof PIXI.TextMetrics
  610. * @type {number[]}
  611. * @private
  612. */
  613. TextMetrics._breakingSpaces = [
  614. 0x0009, // character tabulation
  615. 0x0020, // space
  616. 0x2000, // en quad
  617. 0x2001, // em quad
  618. 0x2002, // en space
  619. 0x2003, // em space
  620. 0x2004, // three-per-em space
  621. 0x2005, // four-per-em space
  622. 0x2006, // six-per-em space
  623. 0x2008, // punctuation space
  624. 0x2009, // thin space
  625. 0x200A, // hair space
  626. 0x205F, // medium mathematical space
  627. 0x3000, // ideographic space
  628. ];