zrender
Version:
A lightweight graphic library providing 2d draw for Apache ECharts
764 lines (652 loc) • 24.8 kB
text/typescript
import * as imageHelper from '../helper/image';
import {
extend,
retrieve2,
retrieve3,
reduce
} from '../../core/util';
import { TextAlign, TextVerticalAlign, ImageLike, Dictionary } from '../../core/types';
import { TextStyleProps } from '../Text';
import { getLineHeight, getWidth, parsePercent } from '../../contain/text';
const STYLE_REG = /\{([a-zA-Z0-9_]+)\|([^}]*)\}/g;
interface InnerTruncateOption {
maxIteration?: number
// If truncate result are less than minChar, ellipsis will not show
// which is better for user hint in some cases
minChar?: number
// When all truncated, use the placeholder
placeholder?: string
maxIterations?: number
}
interface InnerPreparedTruncateOption extends Required<InnerTruncateOption> {
font: string
ellipsis: string
ellipsisWidth: number
contentWidth: number
containerWidth: number
cnCharWidth: number
ascCharWidth: number
}
/**
* Show ellipsis if overflow.
*/
export function truncateText(
text: string,
containerWidth: number,
font: string,
ellipsis?: string,
options?: InnerTruncateOption
): string {
if (!containerWidth) {
return '';
}
const textLines = (text + '').split('\n');
options = prepareTruncateOptions(containerWidth, font, ellipsis, options);
// FIXME
// It is not appropriate that every line has '...' when truncate multiple lines.
for (let i = 0, len = textLines.length; i < len; i++) {
textLines[i] = truncateSingleLine(textLines[i], options as InnerPreparedTruncateOption);
}
return textLines.join('\n');
}
function prepareTruncateOptions(
containerWidth: number,
font: string,
ellipsis?: string,
options?: InnerTruncateOption
): InnerPreparedTruncateOption {
options = options || {};
let preparedOpts = extend({}, options) as InnerPreparedTruncateOption;
preparedOpts.font = font;
ellipsis = retrieve2(ellipsis, '...');
preparedOpts.maxIterations = retrieve2(options.maxIterations, 2);
const minChar = preparedOpts.minChar = retrieve2(options.minChar, 0);
// FIXME
// Other languages?
preparedOpts.cnCharWidth = getWidth('国', font);
// FIXME
// Consider proportional font?
const ascCharWidth = preparedOpts.ascCharWidth = getWidth('a', font);
preparedOpts.placeholder = retrieve2(options.placeholder, '');
// Example 1: minChar: 3, text: 'asdfzxcv', truncate result: 'asdf', but not: 'a...'.
// Example 2: minChar: 3, text: '维度', truncate result: '维', but not: '...'.
let contentWidth = containerWidth = Math.max(0, containerWidth - 1); // Reserve some gap.
for (let i = 0; i < minChar && contentWidth >= ascCharWidth; i++) {
contentWidth -= ascCharWidth;
}
let ellipsisWidth = getWidth(ellipsis, font);
if (ellipsisWidth > contentWidth) {
ellipsis = '';
ellipsisWidth = 0;
}
contentWidth = containerWidth - ellipsisWidth;
preparedOpts.ellipsis = ellipsis;
preparedOpts.ellipsisWidth = ellipsisWidth;
preparedOpts.contentWidth = contentWidth;
preparedOpts.containerWidth = containerWidth;
return preparedOpts;
}
function truncateSingleLine(textLine: string, options: InnerPreparedTruncateOption): string {
const containerWidth = options.containerWidth;
const font = options.font;
const contentWidth = options.contentWidth;
if (!containerWidth) {
return '';
}
let lineWidth = getWidth(textLine, font);
if (lineWidth <= containerWidth) {
return textLine;
}
for (let j = 0; ; j++) {
if (lineWidth <= contentWidth || j >= options.maxIterations) {
textLine += options.ellipsis;
break;
}
const subLength = j === 0
? estimateLength(textLine, contentWidth, options.ascCharWidth, options.cnCharWidth)
: lineWidth > 0
? Math.floor(textLine.length * contentWidth / lineWidth)
: 0;
textLine = textLine.substr(0, subLength);
lineWidth = getWidth(textLine, font);
}
if (textLine === '') {
textLine = options.placeholder;
}
return textLine;
}
function estimateLength(
text: string, contentWidth: number, ascCharWidth: number, cnCharWidth: number
): number {
let width = 0;
let i = 0;
for (let len = text.length; i < len && width < contentWidth; i++) {
const charCode = text.charCodeAt(i);
width += (0 <= charCode && charCode <= 127) ? ascCharWidth : cnCharWidth;
}
return i;
}
export interface PlainTextContentBlock {
lineHeight: number
// Line height of actual content.
calculatedLineHeight: number
contentWidth: number
contentHeight: number
width: number
height: number
/**
* Real text width containing padding.
* It should be the same as `width` if background is rendered
* and `width` is set by user.
*/
outerWidth: number
outerHeight: number
lines: string[]
}
export function parsePlainText(
text: string,
style?: TextStyleProps
): PlainTextContentBlock {
text != null && (text += '');
// textPadding has been normalized
const overflow = style.overflow;
const padding = style.padding as number[];
const font = style.font;
const truncate = overflow === 'truncate';
const calculatedLineHeight = getLineHeight(font);
const lineHeight = retrieve2(style.lineHeight, calculatedLineHeight);
const bgColorDrawn = !!(style.backgroundColor);
const truncateLineOverflow = style.lineOverflow === 'truncate';
let width = style.width;
let lines: string[];
if (width != null && (overflow === 'break' || overflow === 'breakAll')) {
lines = text ? wrapText(text, style.font, width, overflow === 'breakAll', 0).lines : [];
}
else {
lines = text ? text.split('\n') : [];
}
const contentHeight = lines.length * lineHeight;
const height = retrieve2(style.height, contentHeight);
// Truncate lines.
if (contentHeight > height && truncateLineOverflow) {
const lineCount = Math.floor(height / lineHeight);
lines = lines.slice(0, lineCount);
// TODO If show ellipse for line truncate
// if (style.ellipsis) {
// const options = prepareTruncateOptions(width, font, style.ellipsis, {
// minChar: style.truncateMinChar,
// placeholder: style.placeholder
// });
// lines[lineCount - 1] = truncateSingleLine(lastLine, options);
// }
}
if (text && truncate && width != null) {
const options = prepareTruncateOptions(width, font, style.ellipsis, {
minChar: style.truncateMinChar,
placeholder: style.placeholder
});
// Having every line has '...' when truncate multiple lines.
for (let i = 0; i < lines.length; i++) {
lines[i] = truncateSingleLine(lines[i], options);
}
}
// Calculate real text width and height
let outerHeight = height;
let contentWidth = 0;
for (let i = 0; i < lines.length; i++) {
contentWidth = Math.max(getWidth(lines[i], font), contentWidth);
}
if (width == null) {
// When width is not explicitly set, use outerWidth as width.
width = contentWidth;
}
let outerWidth = contentWidth;
if (padding) {
outerHeight += padding[0] + padding[2];
outerWidth += padding[1] + padding[3];
width += padding[1] + padding[3];
}
if (bgColorDrawn) {
// When render background, outerWidth should be the same as width.
outerWidth = width;
}
return {
lines: lines,
height: height,
outerWidth: outerWidth,
outerHeight: outerHeight,
lineHeight: lineHeight,
calculatedLineHeight: calculatedLineHeight,
contentWidth: contentWidth,
contentHeight: contentHeight,
width: width
};
}
class RichTextToken {
styleName: string
text: string
width: number
height: number
// Inner height exclude padding
innerHeight: number
// Width and height of actual text content.
contentHeight: number
contentWidth: number
lineHeight: number
font: string
align: TextAlign
verticalAlign: TextVerticalAlign
textPadding: number[]
percentWidth?: string
isLineHolder: boolean
}
class RichTextLine {
lineHeight: number
width: number
tokens: RichTextToken[] = []
constructor(tokens?: RichTextToken[]) {
if (tokens) {
this.tokens = tokens;
}
}
}
export class RichTextContentBlock {
// width/height of content
width: number = 0
height: number = 0
// Calculated text height
contentWidth: number = 0
contentHeight: number = 0
// outerWidth/outerHeight with padding
outerWidth: number = 0
outerHeight: number = 0
lines: RichTextLine[] = []
}
type WrapInfo = {
width: number,
accumWidth: number,
breakAll: boolean
}
/**
* For example: 'some text {a|some text}other text{b|some text}xxx{c|}xxx'
* Also consider 'bbbb{a|xxx\nzzz}xxxx\naaaa'.
* If styleName is undefined, it is plain text.
*/
export function parseRichText(text: string, style: TextStyleProps) {
const contentBlock = new RichTextContentBlock();
text != null && (text += '');
if (!text) {
return contentBlock;
}
const topWidth = style.width;
const topHeight = style.height;
const overflow = style.overflow;
let wrapInfo: WrapInfo = (overflow === 'break' || overflow === 'breakAll') && topWidth != null
? {width: topWidth, accumWidth: 0, breakAll: overflow === 'breakAll'}
: null;
let lastIndex = STYLE_REG.lastIndex = 0;
let result;
while ((result = STYLE_REG.exec(text)) != null) {
const matchedIndex = result.index;
if (matchedIndex > lastIndex) {
pushTokens(contentBlock, text.substring(lastIndex, matchedIndex), style, wrapInfo);
}
pushTokens(contentBlock, result[2], style, wrapInfo, result[1]);
lastIndex = STYLE_REG.lastIndex;
}
if (lastIndex < text.length) {
pushTokens(contentBlock, text.substring(lastIndex, text.length), style, wrapInfo);
}
// For `textWidth: xx%`
let pendingList = [];
let calculatedHeight = 0;
let calculatedWidth = 0;
const stlPadding = style.padding as number[];
const truncate = overflow === 'truncate';
const truncateLine = style.lineOverflow === 'truncate';
// let prevToken: RichTextToken;
function finishLine(line: RichTextLine, lineWidth: number, lineHeight: number) {
line.width = lineWidth;
line.lineHeight = lineHeight;
calculatedHeight += lineHeight;
calculatedWidth = Math.max(calculatedWidth, lineWidth);
}
// Calculate layout info of tokens.
outer: for (let i = 0; i < contentBlock.lines.length; i++) {
const line = contentBlock.lines[i];
let lineHeight = 0;
let lineWidth = 0;
for (let j = 0; j < line.tokens.length; j++) {
const token = line.tokens[j];
const tokenStyle = token.styleName && style.rich[token.styleName] || {};
// textPadding should not inherit from style.
const textPadding = token.textPadding = tokenStyle.padding as number[];
const paddingH = textPadding ? textPadding[1] + textPadding[3] : 0;
const font = token.font = tokenStyle.font || style.font;
token.contentHeight = getLineHeight(font);
// textHeight can be used when textVerticalAlign is specified in token.
let tokenHeight = retrieve2(
// textHeight should not be inherited, consider it can be specified
// as box height of the block.
tokenStyle.height, token.contentHeight
);
token.innerHeight = tokenHeight;
textPadding && (tokenHeight += textPadding[0] + textPadding[2]);
token.height = tokenHeight;
// Inlcude padding in lineHeight.
token.lineHeight = retrieve3(
tokenStyle.lineHeight, style.lineHeight, tokenHeight
);
token.align = tokenStyle && tokenStyle.align || style.align;
token.verticalAlign = tokenStyle && tokenStyle.verticalAlign || 'middle';
if (truncateLine && topHeight != null && calculatedHeight + token.lineHeight > topHeight) {
// TODO Add ellipsis on the previous token.
// prevToken.text =
if (j > 0) {
line.tokens = line.tokens.slice(0, j);
finishLine(line, lineWidth, lineHeight);
contentBlock.lines = contentBlock.lines.slice(0, i + 1);
}
else {
contentBlock.lines = contentBlock.lines.slice(0, i);
}
break outer;
}
let styleTokenWidth = tokenStyle.width;
let tokenWidthNotSpecified = styleTokenWidth == null || styleTokenWidth === 'auto';
// Percent width, can be `100%`, can be used in drawing separate
// line when box width is needed to be auto.
if (typeof styleTokenWidth === 'string' && styleTokenWidth.charAt(styleTokenWidth.length - 1) === '%') {
token.percentWidth = styleTokenWidth;
pendingList.push(token);
token.contentWidth = getWidth(token.text, font);
// Do not truncate in this case, because there is no user case
// and it is too complicated.
}
else {
if (tokenWidthNotSpecified) {
// FIXME: If image is not loaded and textWidth is not specified, calling
// `getBoundingRect()` will not get correct result.
const textBackgroundColor = tokenStyle.backgroundColor;
let bgImg = textBackgroundColor && (textBackgroundColor as { image: ImageLike }).image;
if (bgImg) {
bgImg = imageHelper.findExistImage(bgImg);
if (imageHelper.isImageReady(bgImg)) {
// Update token width from image size.
token.width = Math.max(token.width, bgImg.width * tokenHeight / bgImg.height);
}
}
}
const remainTruncWidth = truncate && topWidth != null
? topWidth - lineWidth : null;
if (remainTruncWidth != null && remainTruncWidth < token.width) {
if (!tokenWidthNotSpecified || remainTruncWidth < paddingH) {
token.text = '';
token.width = token.contentWidth = 0;
}
else {
token.text = truncateText(
token.text, remainTruncWidth - paddingH, font, style.ellipsis,
{minChar: style.truncateMinChar}
);
token.width = token.contentWidth = getWidth(token.text, font);
}
}
else {
token.contentWidth = getWidth(token.text, font);
}
}
token.width += paddingH;
lineWidth += token.width;
tokenStyle && (lineHeight = Math.max(lineHeight, token.lineHeight));
// prevToken = token;
}
finishLine(line, lineWidth, lineHeight);
}
contentBlock.outerWidth = contentBlock.width = retrieve2(topWidth, calculatedWidth);
contentBlock.outerHeight = contentBlock.height = retrieve2(topHeight, calculatedHeight);
contentBlock.contentHeight = calculatedHeight;
contentBlock.contentWidth = calculatedWidth;
if (stlPadding) {
contentBlock.outerWidth += stlPadding[1] + stlPadding[3];
contentBlock.outerHeight += stlPadding[0] + stlPadding[2];
}
for (let i = 0; i < pendingList.length; i++) {
const token = pendingList[i];
const percentWidth = token.percentWidth;
// Should not base on outerWidth, because token can not be placed out of padding.
token.width = parseInt(percentWidth, 10) / 100 * contentBlock.width;
}
return contentBlock;
}
type TokenStyle = TextStyleProps['rich'][string];
function pushTokens(
block: RichTextContentBlock,
str: string,
style: TextStyleProps,
wrapInfo: WrapInfo,
styleName?: string
) {
const isEmptyStr = str === '';
const tokenStyle: TokenStyle = styleName && style.rich[styleName] || {};
const lines = block.lines;
const font = tokenStyle.font || style.font;
let newLine = false;
let strLines;
let linesWidths;
if (wrapInfo) {
const tokenPadding = tokenStyle.padding as number[];
let tokenPaddingH = tokenPadding ? tokenPadding[1] + tokenPadding[3] : 0;
if (tokenStyle.width != null && tokenStyle.width !== 'auto') {
// Wrap the whole token if tokenWidth if fixed.
const outerWidth = parsePercent(tokenStyle.width, wrapInfo.width) + tokenPaddingH;
if (lines.length > 0) { // Not first line
if (outerWidth + wrapInfo.accumWidth > wrapInfo.width) {
// TODO Support wrap text in token.
strLines = str.split('\n');
newLine = true;
}
}
wrapInfo.accumWidth = outerWidth;
}
else {
const res = wrapText(str, font, wrapInfo.width, wrapInfo.breakAll, wrapInfo.accumWidth);
wrapInfo.accumWidth = res.accumWidth + tokenPaddingH;
linesWidths = res.linesWidths;
strLines = res.lines;
}
}
else {
strLines = str.split('\n');
}
for (let i = 0; i < strLines.length; i++) {
const text = strLines[i];
const token = new RichTextToken();
token.styleName = styleName;
token.text = text;
token.isLineHolder = !text && !isEmptyStr;
if (typeof tokenStyle.width === 'number') {
token.width = tokenStyle.width;
}
else {
token.width = linesWidths
? linesWidths[i] // Caculated width in the wrap
: getWidth(text, font);
}
// The first token should be appended to the last line if not new line.
if (!i && !newLine) {
const tokens = (lines[lines.length - 1] || (lines[0] = new RichTextLine())).tokens;
// Consider cases:
// (1) ''.split('\n') => ['', '\n', ''], the '' at the first item
// (which is a placeholder) should be replaced by new token.
// (2) A image backage, where token likes {a|}.
// (3) A redundant '' will affect textAlign in line.
// (4) tokens with the same tplName should not be merged, because
// they should be displayed in different box (with border and padding).
const tokensLen = tokens.length;
(tokensLen === 1 && tokens[0].isLineHolder)
? (tokens[0] = token)
// Consider text is '', only insert when it is the "lineHolder" or
// "emptyStr". Otherwise a redundant '' will affect textAlign in line.
: ((text || !tokensLen || isEmptyStr) && tokens.push(token));
}
// Other tokens always start a new line.
else {
// If there is '', insert it as a placeholder.
lines.push(new RichTextLine([token]));
}
}
}
function isAlphabeticLetter(ch: string) {
// Unicode Character Ranges
// https://jrgraphix.net/research/unicode_blocks.php
// The following ranges may not cover all letter ranges but only the more
// popular ones. Developers could make pull requests when they find those
// not covered.
let code = ch.charCodeAt(0);
return code >= 0x20 && code <= 0x24F // Latin
|| code >= 0x370 && code <= 0x10FF // Greek, Coptic, Cyrilic, and etc.
|| code >= 0x1200 && code <= 0x13FF // Ethiopic and Cherokee
|| code >= 0x1E00 && code <= 0x206F; // Latin and Greek extended
}
const breakCharMap = reduce(',&?/;] '.split(''), function (obj, ch) {
obj[ch] = true;
return obj;
}, {} as Dictionary<boolean>);
/**
* If break by word. For latin languages.
*/
function isWordBreakChar(ch: string) {
if (isAlphabeticLetter(ch)) {
if (breakCharMap[ch]) {
return true;
}
return false;
}
return true;
}
function wrapText(
text: string,
font: string,
lineWidth: number,
isBreakAll: boolean,
lastAccumWidth: number
) {
let lines: string[] = [];
let linesWidths: number[] = [];
let line = '';
let currentWord = '';
let currentWordWidth = 0;
let accumWidth = 0;
for (let i = 0; i < text.length; i++) {
const ch = text.charAt(i);
if (ch === '\n') {
if (currentWord) {
line += currentWord;
accumWidth += currentWordWidth;
}
lines.push(line);
linesWidths.push(accumWidth);
// Reset
line = '';
currentWord = '';
currentWordWidth = 0;
accumWidth = 0;
continue;
}
const chWidth = getWidth(ch, font);
const inWord = isBreakAll ? false : !isWordBreakChar(ch);
if (!lines.length
? lastAccumWidth + accumWidth + chWidth > lineWidth
: accumWidth + chWidth > lineWidth
) {
if (!accumWidth) { // If nothing appended yet.
if (inWord) {
// The word length is still too long for one line
// Force break the word
lines.push(currentWord);
linesWidths.push(currentWordWidth);
currentWord = ch;
currentWordWidth = chWidth;
}
else {
// lineWidth is too small for ch
lines.push(ch);
linesWidths.push(chWidth);
}
}
else if (line || currentWord) {
if (inWord) {
if (!line) {
// The one word is still too long for one line
// Force break the word
// TODO Keep the word?
line = currentWord;
currentWord = '';
currentWordWidth = 0;
accumWidth = currentWordWidth;
}
lines.push(line);
linesWidths.push(accumWidth - currentWordWidth);
// Break the whole word
currentWord += ch;
currentWordWidth += chWidth;
line = '';
accumWidth = currentWordWidth;
}
else {
// Append lastWord if have
if (currentWord) {
line += currentWord;
currentWord = '';
currentWordWidth = 0;
}
lines.push(line);
linesWidths.push(accumWidth);
line = ch;
accumWidth = chWidth;
}
}
continue;
}
accumWidth += chWidth;
if (inWord) {
currentWord += ch;
currentWordWidth += chWidth;
}
else {
// Append whole word
if (currentWord) {
line += currentWord;
// Reset
currentWord = '';
currentWordWidth = 0;
}
// Append character
line += ch;
}
}
if (!lines.length && !line) {
line = text;
currentWord = '';
currentWordWidth = 0;
}
// Append last line.
if (currentWord) {
line += currentWord;
}
if (line) {
lines.push(line);
linesWidths.push(accumWidth);
}
if (lines.length === 1) {
// No new line.
accumWidth += lastAccumWidth;
}
return {
// Accum width of last line
accumWidth,
lines: lines,
linesWidths
};
}