From e49bdf2ee683e199791ae475ceecf1bf7f9b1814 Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 14 Jul 2025 16:05:25 +0800 Subject: [PATCH 1/3] fix(Text/TSpan bounding rect): (1) Previously, plain text bounding rect is inconsistent (contain stroke width or not) according to style.width & style.overflow (even if no wrapping or truncation happen and text is visually the same). This commit makes it consistent. (2) Previously, the bounding rect varies and unpredictable according to the internal auto-stroke application. This commit exclude auto-stroke width uniformly, since auto-stroke using the same color as background to make the border invisible. (3) deprecated `src/contain/text.ts` `getBoundingRect`, because it behave differently from Text#getBoundingRect, and unexpectedly when text contains "\n". --- src/contain/text.ts | 5 ++- src/graphic/TSpan.ts | 33 ++-------------- src/graphic/Text.ts | 65 +++++++++++++++++-------------- src/graphic/helper/parseText.ts | 69 ++++++++++++++++++++++++++++++--- 4 files changed, 107 insertions(+), 65 deletions(-) diff --git a/src/contain/text.ts b/src/contain/text.ts index 6abb2a874..cdafcd6d8 100644 --- a/src/contain/text.ts +++ b/src/contain/text.ts @@ -106,7 +106,7 @@ export function measureWidth(fontMeasureInfo: FontMeasureInfo, text: string): nu /** - * + * @deprecated See `getBoundingRect`. * Get bounding rect for inner usage(TSpan) * Which not include text newline. */ @@ -128,6 +128,9 @@ export function innerGetBoundingRect( } /** + * @deprecated Use `(new Text(...)).getBoundingRect()` or `(new TSpan(...)).getBoundingRect()` instead. + * This method behaves differently from `Text#getBoundingRect()` - e.g., it does not support the overflow + * strategy, and only has single line height even if multiple lines. * * Get bounding rect for outer usage. Compatitable with old implementation * Which includes text newline. diff --git a/src/graphic/TSpan.ts b/src/graphic/TSpan.ts index 4b5012901..40496b8cd 100644 --- a/src/graphic/TSpan.ts +++ b/src/graphic/TSpan.ts @@ -1,10 +1,10 @@ import Displayable, { DisplayableProps, DisplayableStatePropNames } from './Displayable'; -import { getBoundingRect } from '../contain/text'; import BoundingRect from '../core/BoundingRect'; import { PathStyleProps, DEFAULT_PATH_STYLE } from './Path'; import { createObject, defaults } from '../core/util'; -import { FontStyle, FontWeight, TextAlign, TextVerticalAlign } from '../core/types'; +import { FontStyle, FontWeight } from '../core/types'; import { DEFAULT_FONT } from '../core/platform'; +import { tSpanCreateBoundingRect, tSpanHasStroke } from './helper/parseText'; export interface TSpanStyleProps extends PathStyleProps { @@ -53,9 +53,7 @@ class TSpan extends Displayable { style: TSpanStyleProps hasStroke() { - const style = this.style; - const stroke = style.stroke; - return stroke != null && stroke !== 'none' && style.lineWidth > 0; + return tSpanHasStroke(this.style); } hasFill() { @@ -81,31 +79,8 @@ class TSpan extends Displayable { } getBoundingRect(): BoundingRect { - const style = this.style; - if (!this._rect) { - let text = style.text; - text != null ? (text += '') : (text = ''); - - const rect = getBoundingRect( - text, - style.font, - style.textAlign as TextAlign, - style.textBaseline as TextVerticalAlign - ); - - rect.x += style.x || 0; - rect.y += style.y || 0; - - if (this.hasStroke()) { - const w = style.lineWidth; - rect.x -= w / 2; - rect.y -= w / 2; - rect.width += w; - rect.height += w; - } - - this._rect = rect; + this._rect = tSpanCreateBoundingRect(this.style); } return this._rect; diff --git a/src/graphic/Text.ts b/src/graphic/Text.ts index c9512789d..45f15b255 100644 --- a/src/graphic/Text.ts +++ b/src/graphic/Text.ts @@ -6,7 +6,8 @@ import { TextAlign, TextVerticalAlign, ImageLike, Dictionary, MapToType, FontWeight, FontStyle, NullUndefined } from '../core/types'; import { - parseRichText, parsePlainText, CalcInnerTextOverflowAreaOut, calcInnerTextOverflowArea + parseRichText, parsePlainText, CalcInnerTextOverflowAreaOut, calcInnerTextOverflowArea, + tSpanCreateBoundingRect2, } from './helper/parseText'; import TSpan, { TSpanStyleProps } from './TSpan'; import { retrieve2, each, normalizeCssArray, trim, retrieve3, extend, keys, defaults } from '../core/util'; @@ -332,7 +333,7 @@ class ZRText extends Displayable implements GroupLike { } } - updateTransform() { + updateTransform() { const innerTransformable = this.innerTransformable; if (innerTransformable) { innerTransformable.updateTransform(); @@ -528,7 +529,6 @@ class ZRText extends Displayable implements GroupLike { const outerHeight = contentBlock.outerHeight; const outerWidth = contentBlock.outerWidth; - const contentWidth = contentBlock.contentWidth; const textLines = contentBlock.lines; const lineHeight = contentBlock.lineHeight; @@ -559,6 +559,7 @@ class ZRText extends Displayable implements GroupLike { } let defaultLineWidth = 0; + let usingDefaultStroke = false; let useDefaultFill = false; const textFill = getFill( 'fill' in style @@ -580,16 +581,12 @@ class ZRText extends Displayable implements GroupLike { // we give the auto lineWidth to display the given stoke color. && (!defaultStyle.autoStroke || useDefaultFill) ) - ? (defaultLineWidth = DEFAULT_STROKE_LINE_WIDTH, defaultStyle.stroke) + ? (defaultLineWidth = DEFAULT_STROKE_LINE_WIDTH, usingDefaultStroke = true, defaultStyle.stroke) : null ); const hasShadow = style.textShadowBlur > 0; - const fixedBoundingRect = style.width != null - && (style.overflow === 'truncate' || style.overflow === 'break' || style.overflow === 'breakAll'); - const calculatedLineHeight = contentBlock.calculatedLineHeight; - for (let i = 0; i < textLines.length; i++) { const el = this._getOrCreateChild(TSpan); // Always create new style. @@ -633,19 +630,27 @@ class ZRText extends Displayable implements GroupLike { textY += lineHeight; - if (fixedBoundingRect) { - el.setBoundingRect(new BoundingRect( - adjustTextX(subElStyle.x, contentWidth, subElStyle.textAlign as TextAlign), - adjustTextY(subElStyle.y, calculatedLineHeight, subElStyle.textBaseline as TextVerticalAlign), - /** - * Text boundary should be the real text width. - * Otherwise, there will be extra space in the - * bounding rect calculated. - */ - contentWidth, - calculatedLineHeight - )); - } + // Always set tspan bounding rect to guarantee the consistency if users lays out based + // on these bounding rects. + el.setBoundingRect(tSpanCreateBoundingRect2( + subElStyle, + contentBlock.contentWidth, + contentBlock.calculatedLineHeight, + // Should text bounding rect includes text stroke width? + // Pros: + // - Intuitively, and by convention, bounding rect of `Path` always includes stroke width. + // Cons: + // - It's unpredictable for users whether "auto stroke" is applied. If stroke width is included + // and multiple texts are laid out based on its bounding rect, the position of texts may vary + // and is unpredictable - especially in limited space (e.g., see echarts pie label cases). + // - "auto stroke" attempts to use the same color as the background to make the border to be + // invisible in most cases, thus it might be more reasonable to be excluded from bounding rect. + // Conclusion: + // - If users specifies style.stroke, it will be included into the bounding rect as normal. + // Otherwise, keep the stroke width as `0` in this case to guarantee consistency of bounding + // rect based layout, regardless of whether "auto stroke" is applied. + usingDefaultStroke ? 0 : null + )); } } @@ -801,6 +806,7 @@ class ZRText extends Displayable implements GroupLike { const defaultStyle = this._defaultStyle; let useDefaultFill = false; let defaultLineWidth = 0; + let usingDefaultStroke = false; const textFill = getFill( 'fill' in tokenStyle ? tokenStyle.fill : 'fill' in style ? style.fill @@ -812,9 +818,9 @@ class ZRText extends Displayable implements GroupLike { : ( !bgColorDrawn && !parentBgColorDrawn - // See the strategy explained above. + // See the strategy explained `_updatePlainTexts`. && (!defaultStyle.autoStroke || useDefaultFill) - ) ? (defaultLineWidth = DEFAULT_STROKE_LINE_WIDTH, defaultStyle.stroke) + ) ? (defaultLineWidth = DEFAULT_STROKE_LINE_WIDTH, usingDefaultStroke = true, defaultStyle.stroke) : null ); @@ -852,14 +858,13 @@ class ZRText extends Displayable implements GroupLike { subElStyle.fill = textFill; } - const textWidth = token.contentWidth; - const textHeight = token.contentHeight; // NOTE: Should not call dirtyStyle after setBoundingRect. Or it will be cleared. - el.setBoundingRect(new BoundingRect( - adjustTextX(subElStyle.x, textWidth, subElStyle.textAlign as TextAlign), - adjustTextY(subElStyle.y, textHeight, subElStyle.textBaseline as TextVerticalAlign), - textWidth, - textHeight + el.setBoundingRect(tSpanCreateBoundingRect2( + subElStyle, + token.contentWidth, + token.contentHeight, + // See the strategy explained `_updatePlainTexts`. + usingDefaultStroke ? 0 : null )); } diff --git a/src/graphic/helper/parseText.ts b/src/graphic/helper/parseText.ts index 22a28ba2a..69c63a694 100644 --- a/src/graphic/helper/parseText.ts +++ b/src/graphic/helper/parseText.ts @@ -6,7 +6,8 @@ import { reduce, } from '../../core/util'; import { TextAlign, TextVerticalAlign, ImageLike, Dictionary, NullUndefined } from '../../core/types'; -import { DefaultTextStyle, TextStyleProps } from '../Text'; +import type { DefaultTextStyle, TextStyleProps } from '../Text'; +import type { TSpanStyleProps } from '../TSpan'; import { adjustTextX, adjustTextY, @@ -210,12 +211,12 @@ export interface PlainTextContentBlock { } export function parsePlainText( - text: string, + rawText: unknown, style: Omit, // Exclude props in DefaultTextStyle defaultOuterWidth: number | NullUndefined, defaultOuterHeight: number | NullUndefined ): PlainTextContentBlock { - text != null && (text += ''); + const text = formatText(rawText); // textPadding has been normalized const overflow = style.overflow; @@ -382,7 +383,7 @@ type WrapInfo = { * If styleName is undefined, it is plain text. */ export function parseRichText( - text: string, + rawText: unknown, style: Omit, // Exclude props in DefaultTextStyle defaultOuterWidth: number | NullUndefined, defaultOuterHeight: number | NullUndefined, @@ -390,7 +391,7 @@ export function parseRichText( ): RichTextContentBlock { const contentBlock = new RichTextContentBlock(); - text != null && (text += ''); + const text = formatText(rawText); if (!text) { return contentBlock; } @@ -890,3 +891,61 @@ export type CalcInnerTextOverflowAreaOut = { outerHeight: number | NullUndefined }; +// For backward compatibility, and possibly loose type. +function formatText(text: unknown): string { + return text != null ? (text += '') : (text = ''); +} + +export function tSpanCreateBoundingRect( + style: Pick, +): BoundingRect { + // Should follow the same way as `parsePlainText` to guarantee the consistency of the result. + const text = formatText(style.text); + const font = style.font; + const contentWidth = measureWidth(ensureFontMeasureInfo(font), text); + const contentHeight = getLineHeight(font); + + return tSpanCreateBoundingRect2( + style, + contentWidth, + contentHeight, + null + ); +} + +export function tSpanCreateBoundingRect2( + style: Pick, + contentWidth: number, + contentHeight: number, + forceLineWidth: number | NullUndefined, +): BoundingRect { + const rect = new BoundingRect( + adjustTextX(style.x || 0, contentWidth, style.textAlign as TextAlign), + adjustTextY(style.y || 0, contentHeight, style.textBaseline as TextVerticalAlign), + /** + * Text boundary should be the real text width. + * Otherwise, there will be extra space in the + * bounding rect calculated. + */ + contentWidth, + contentHeight + ); + + const lineWidth = forceLineWidth != null + ? forceLineWidth + : (tSpanHasStroke(style) ? style.lineWidth : 0); + if (lineWidth > 0) { + rect.x -= lineWidth / 2; + rect.y -= lineWidth / 2; + rect.width += lineWidth; + rect.height += lineWidth; + } + + return rect; +} + +export function tSpanHasStroke(style: TSpanStyleProps): boolean { + const stroke = style.stroke; + return stroke != null && stroke !== 'none' && style.lineWidth > 0; +} + From a96a754726951e85c1ea6217c77a1d00c00a0467 Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 14 Jul 2025 17:16:38 +0800 Subject: [PATCH 2/3] test: update text test. --- test/text.html | 168 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 129 insertions(+), 39 deletions(-) diff --git a/test/text.html b/test/text.html index d2de00fd8..9ca145e6b 100644 --- a/test/text.html +++ b/test/text.html @@ -8,15 +8,14 @@ -
+
+
-
+
From 5825c40ce2d542079732c0efe42b0d8ce0aae1e4 Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 14 Jul 2025 17:57:42 +0800 Subject: [PATCH 3/3] Update comment. --- src/graphic/Text.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/graphic/Text.ts b/src/graphic/Text.ts index 45f15b255..6d0e8a59e 100644 --- a/src/graphic/Text.ts +++ b/src/graphic/Text.ts @@ -544,6 +544,13 @@ class ZRText extends Displayable implements GroupLike { const boxY = adjustTextY(baseY, outerHeight, verticalAlign); needDrawBg && this._renderBackground(style, style, boxX, boxY, outerWidth, outerHeight); } + // PENDING: + // Should text bounding rect contains style.padding, style.width, style.height when NO background + // and border displayed? It depends on how to define "boundingRect". HTML `getBoundingClientRect` + // contains padding in that case. But currently ZRText does not. + // If implement that, an extra invisible Rect may need to be added as the placeholder for the bounding + // rect computation, considering animation of padding. But will it degrade performance for the most + // used plain texts cases? // `textBaseline` is set as 'middle'. textY += lineHeight / 2;