diff --git a/common/changes/@visactor/vchart-extension/fix-pictogram-text-anchor_2026-09-15.json b/common/changes/@visactor/vchart-extension/fix-pictogram-text-anchor_2026-09-15.json new file mode 100644 index 0000000000..280943f1ef --- /dev/null +++ b/common/changes/@visactor/vchart-extension/fix-pictogram-text-anchor_2026-09-15.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart-extension", + "comment": "修复 Pictogram SVG text-anchor 水平对齐映射,避免居中或右对齐文字错误偏移及被裁剪,并保留 SVG 画布适配行为。", + "type": "patch" + } + ], + "packageName": "@visactor/vchart-extension" +} diff --git a/docs/assets/guide/en/tutorial_docs/Chart_Types/Pictogram.md b/docs/assets/guide/en/tutorial_docs/Chart_Types/Pictogram.md index 2c5acf0950..2983b42606 100644 --- a/docs/assets/guide/en/tutorial_docs/Chart_Types/Pictogram.md +++ b/docs/assets/guide/en/tutorial_docs/Chart_Types/Pictogram.md @@ -36,6 +36,8 @@ SVG characters can be used as materials, and the SVG primitives currently suppor Including defs/style/switch/C/Q/pattern/use, etc. are not supported for the time being. +For left-to-right text, `text-anchor` supports `start`, `middle`, and `end`, producing left-aligned, centered, and right-aligned text relative to the anchor, respectively. This attribute is inherited through `g`, `text`, and `tspan`, and child elements can override the inherited value. Text defaults to left alignment when no anchor is specified. Horizontal anchoring does not change the vertical baseline. + Here is a simple example, in this example, the chart has no data, so there is no data mapping, just a display of SVG materials. ```javascript livedemo diff --git a/docs/assets/guide/zh/tutorial_docs/Chart_Types/Pictogram.md b/docs/assets/guide/zh/tutorial_docs/Chart_Types/Pictogram.md index 987ab80dda..a47f177e73 100644 --- a/docs/assets/guide/zh/tutorial_docs/Chart_Types/Pictogram.md +++ b/docs/assets/guide/zh/tutorial_docs/Chart_Types/Pictogram.md @@ -38,6 +38,8 @@ 包括 defs/style/switch/C/Q/pattern/use 等用法暂不支持。 +对于从左到右的文字,`text-anchor` 支持 `start`、`middle`、`end`,分别表示相对锚点左对齐、居中、右对齐。该属性可通过 `g`、`text`、`tspan` 继承,子元素可覆盖继承值;未配置时默认左对齐。水平锚点不会改变文字的垂直基线。 + 这里有一个简单的例子,这个例子中,图表没有任何数据,所以没有任何数据映射,只是一个对 SVG 素材的展示。 ```javascript livedemo diff --git a/packages/vchart-extension/src/charts/pictogram/series/transform.ts b/packages/vchart-extension/src/charts/pictogram/series/transform.ts index 469fe25d7d..00bf73caf6 100644 --- a/packages/vchart-extension/src/charts/pictogram/series/transform.ts +++ b/packages/vchart-extension/src/charts/pictogram/series/transform.ts @@ -1,7 +1,13 @@ import { isValid, merge } from '@visactor/vchart'; -import type { DataView, SVGParserResult } from '@visactor/vchart'; +import type { DataView, ITextGraphicAttribute, SVGParserResult } from '@visactor/vchart'; import { DEFAULT_DATA_INDEX, measureText } from '@visactor/vchart'; +const SVG_TEXT_ANCHOR_TO_ALIGN: Record = { + start: 'left', + middle: 'center', + end: 'right' +}; + function isValidStrokeOrFill(attr: any) { return isValid(attr) && attr !== 'none' && !attr.includes?.('url'); } @@ -114,8 +120,7 @@ export const graphicAttributeTransform = { return { ...commonAttributes(attributes), text: value, - textAlign: attributes.textAlign ?? 'left', - textBaseLine: attributes.textAnchor ?? 'middle', + textAlign: attributes.textAlign ?? SVG_TEXT_ANCHOR_TO_ALIGN[attributes.textAnchor] ?? 'left', anchor: [0, 0], fill: getFill(attributes, '#000') }; diff --git a/packages/vchart/__tests__/unit/extension/pictogram-text-anchor.test.ts b/packages/vchart/__tests__/unit/extension/pictogram-text-anchor.test.ts new file mode 100644 index 0000000000..8410300314 --- /dev/null +++ b/packages/vchart/__tests__/unit/extension/pictogram-text-anchor.test.ts @@ -0,0 +1,146 @@ +import VChart, { createText } from '../../../src'; +import type { ITextGraphicAttribute } from '../../../src'; +import { registerBrowserEnv } from '../../../src/env'; +import { createDiv, removeDom } from '../../util/dom'; +import { + graphicAttributeTransform, + pictogram +} from '../../../../vchart-extension/src/charts/pictogram/series/transform'; +import { registerPictogramChart } from '../../../../vchart-extension/src/charts/pictogram/pictogram'; +import type { IPictogramChartSpec } from '../../../../vchart-extension/src/charts/pictogram/interface'; +import type { SVGParsedElementExtend } from '../../../../vchart-extension/src/charts/pictogram/series/pictogram'; +import { + clearSVGSource, + getSVGSource, + registerSVGSource +} from '../../../../vchart-extension/src/charts/pictogram/series/svg-source'; + +const registerTextSVG = (body: string) => + registerSVGSource('anchor-test', `${body}`); + +describe('pictogram text anchor', () => { + beforeAll(() => registerBrowserEnv()); + afterEach(() => clearSVGSource()); + + it.each([ + ['start', 'left', 0], + ['middle', 'center', 0.5], + ['end', 'right', 1] + ])('positions text using %s', (textAnchor, textAlign, ratio) => { + const attributes = graphicAttributeTransform.text( + { x: 100, y: 40, textAnchor, fontSize: 12, fontFamily: 'Arial' }, + 'WWWW' + ); + const graphic = createText(attributes as unknown as ITextGraphicAttribute); + + try { + const bounds = graphic.AABBBounds; + expect(attributes.textAlign).toBe(textAlign); + expect(bounds.width()).toBeGreaterThan(0); + expect(bounds.x1 + bounds.width() * Number(ratio)).toBeCloseTo(100); + expect(attributes).not.toHaveProperty('textBaseLine'); + expect(attributes).not.toHaveProperty('textBaseline'); + } finally { + graphic.release(); + } + }); + + it('retains explicit textAlign priority and defaults to left', () => { + expect(graphicAttributeTransform.text({ textAnchor: 'middle', textAlign: 'right' }, 'A').textAlign).toBe('right'); + expect(graphicAttributeTransform.text({}, 'A').textAlign).toBe('left'); + }); + + it('preserves the vertical baseline when changing the horizontal anchor', () => { + const graphics = ['start', 'middle', 'end'].map(textAnchor => + createText( + graphicAttributeTransform.text( + { x: 100, y: 40, textAnchor, fontSize: 12 }, + 'WWWW' + ) as unknown as ITextGraphicAttribute + ) + ); + const reference = createText({ x: 100, y: 40, fontSize: 12, text: 'WWWW', textBaseline: 'alphabetic' }); + + try { + for (const graphic of graphics) { + expect(graphic.AABBBounds.y1).toBeCloseTo(reference.AABBBounds.y1); + expect(graphic.AABBBounds.y2).toBeCloseTo(reference.AABBBounds.y2); + } + } finally { + graphics.forEach(graphic => graphic.release()); + reference.release(); + } + }); + + it('preserves an explicit vertical baseline independently of the anchor', () => { + expect(graphicAttributeTransform.text({ textAnchor: 'middle', textBaseline: 'top' }, 'A')).toHaveProperty( + 'textBaseline', + 'top' + ); + }); + + it.each([ + ['ABC', 'center'], + ['ABC', 'right'], + ['ABC', 'center'], + ['ABC', 'right'] + ])('inherits and overrides SVG anchors: %s', (body, alignment) => { + registerTextSVG(body); + const elements = pictogram([getSVGSource('anchor-test')!]) as SVGParsedElementExtend[]; + const texts = elements.filter(element => element.graphicType === 'text'); + + expect(texts).toHaveLength(1); + expect(texts[0]._finalAttributes.textAlign).toBe(alignment); + }); + + it('keeps centered text inside the SVG viewport after rendering and resizing', async () => { + registerPictogramChart(); + registerTextSVG( + 'WWWW' + ); + const dom = createDiv(); + const chart = new VChart( + { + type: 'pictogram', + width: 200, + height: 100, + padding: 0, + svg: 'anchor-test', + data: { values: [] } + } as IPictogramChartSpec, + { dom, animation: false } + ); + + try { + chart.renderSync(); + const series = chart.getChart().getAllSeries()[0] as any; + const textElement = series._parsedSvgResult.elements.find( + (element: SVGParsedElementExtend) => element.graphicType === 'text' + ); + const textMark = series._idToMark.get(textElement._uniqueId); + const root = series.getPictogramRootGraphic(); + const getTextBounds = () => textMark.getGraphics()[0].globalAABBBounds; + const bounds = getTextBounds(); + const originalY = { y1: bounds.y1, y2: bounds.y2 }; + + expect(bounds.width()).toBeGreaterThan(0); + expect((bounds.x1 + bounds.x2) / 2).toBeCloseTo(170); + expect(bounds.x2).toBeLessThanOrEqual(200); + expect(root.attribute.clip).toBe(true); + expect(root.attribute.postMatrix).toEqual(expect.objectContaining({ a: 1, d: 1, e: 0, f: 0 })); + + await chart.resize(400, 200); + + const resizedBounds = getTextBounds(); + expect((resizedBounds.x1 + resizedBounds.x2) / 2).toBeCloseTo(340); + expect(resizedBounds.x2).toBeLessThanOrEqual(400); + expect(resizedBounds.y1).toBeCloseTo(originalY.y1 * 2); + expect(resizedBounds.y2).toBeCloseTo(originalY.y2 * 2); + expect(root.attribute.clip).toBe(true); + expect(root.attribute.postMatrix).toEqual(expect.objectContaining({ a: 2, d: 2, e: 0, f: 0 })); + } finally { + chart.release(); + removeDom(dom); + } + }); +});