diff --git a/src/svg/graphic.ts b/src/svg/graphic.ts index a5ba320..7ea45e1 100644 --- a/src/svg/graphic.ts +++ b/src/svg/graphic.ts @@ -11,6 +11,7 @@ import { scientificToDecimal, convertTextStyle, convertTextContent, + convertTextBackground, convertRichTextContent, generateGradient, generateDefs, @@ -118,19 +119,36 @@ export const parseSimpleGraphic = (attribute: any, group: any) => { } if (group.type === "text") { - return `${generateDefs(defs)}${convertTextContent(attribute, group)}`; } if (group.type === "richtext") { - return `${generateDefs(defs)}${convertRichTextContent(attribute, group)}`; } diff --git a/src/svg/util.ts b/src/svg/util.ts index 3d6cd1a..b4d4233 100644 --- a/src/svg/util.ts +++ b/src/svg/util.ts @@ -295,8 +295,21 @@ export function convertTextContent(attribute: any = {}, graphic: any): string { const lines = layoutData.lines; if (lines && lines.length) { + // Canvas positions each line at (leftOffset + bbox.xOffset, topOffset + bbox.yOffset), + // where bbox.xOffset/yOffset come from textAlign/textBaseline. The previous + // implementation only used the per-line leftOffset/topOffset and dropped the + // bbox-level offset, relying instead on alignment-baseline on the wrapping . + // But alignment-baseline is not inherited and has no effect on , so labels + // ended up vertically misaligned. Bake both offsets into the coordinates and pin + // text-anchor=start / alphabetic baseline to match the canvas renderer. + const bbox = layoutData.bbox || {}; + const bboxXOffset = bbox.xOffset || 0; + const bboxYOffset = bbox.yOffset || 0; + return lines.reduce((res: string, line: any) => { - return `${res}${line.str}`; + return `${res}${line.str}`; }, ""); } } @@ -318,19 +331,86 @@ export function convertRichTextContent( const lines = frameCache.lines; if (lines && lines.length) { + // Canvas renders richtext (see vrender Frame.draw) by translating the whole text + // block by deltaX/deltaY derived from globalAlign/globalBaseline so it is centered + // (or right/bottom aligned) relative to the anchor. The previous implementation only + // used the per-line line.left / line.top+baseline and dropped this block-level offset, + // while relying on the non-inherited alignment-baseline of the wrapping . As a + // result labels (e.g. text at the end of pie leader lines) were shifted down. Replicate + // Frame.draw's deltaX/deltaY here and bake them into the coordinates. + const globalAlign = frameCache.globalAlign; + const globalBaseline = frameCache.globalBaseline; + + let actualWidth = 0; + let actualHeight = 0; + if (typeof frameCache.getActualSize === "function") { + const actualSize = frameCache.getActualSize(); + actualWidth = actualSize.width || 0; + actualHeight = actualSize.height || 0; + } + + const contentWidth = frameCache.width || actualWidth || 0; + let contentHeight = frameCache.height || actualHeight || 0; + contentHeight = Math.min(contentHeight, actualHeight || contentHeight); + + let deltaX = 0; + if (globalAlign === "right" || globalAlign === "end") { + deltaX = -contentWidth; + } else if (globalAlign === "center") { + deltaX = -contentWidth / 2; + } + + let deltaY = 0; + if (globalBaseline === "middle") { + deltaY = -contentHeight / 2; + } else if (globalBaseline === "bottom") { + deltaY = -contentHeight; + } + + // Map the canvas textBaseline to the SVG dominant-baseline. When drawing, canvas + // computes the baseline y from paragraph.textBaseline (for "middle" ascent=height/2, + // i.e. the baseline y sits at the glyph's vertical center). The SVG side must use the + // matching dominant-baseline, otherwise a hardcoded alphabetic makes "middle" text sit + // on the alphabetic baseline and appear shifted up by roughly (ascent - descent). + const toDominantBaseline = (textBaseline: string): string => { + if (textBaseline === "middle") { + return "central"; + } + if (textBaseline === "top" || textBaseline === "hanging") { + return "text-before-edge"; + } + if (textBaseline === "bottom") { + return "text-after-edge"; + } + return "alphabetic"; + }; + return lines.reduce((res: string, line: any) => { const paragraphs = line.paragraphs; paragraphs.forEach((p: any) => { const pAttrs = { ...line, ...p, ...p.character }; - res = `${res} = { ...convertCommonStyle(pAttrs, graphic), // ...convertTransformStyle(p), ...convertTextStyle(pAttrs), - })} dy="${line.top + line.baseline}" dx="${line.left}">${ - p.text - }`; + }; + // Block-level offset is baked into the coordinates; pin text-anchor=start and map + // the baseline from the paragraph's actual textBaseline to match the canvas. + style["text-anchor"] = "start"; + delete style["alignment-baseline"]; + style["dominant-baseline"] = toDominantBaseline( + p.textBaseline || (p.character && p.character.textBaseline) + ); + + // Canvas draws each line baseline at line.top + line.ascent = line.baseline (see the + // Line constructor: top = baseline - ascent). The previous code used + // line.top + line.baseline, adding an extra line.top (~lineHeight) from the second + // line onward, which stretched the spacing of multi-line labels (e.g. name + value). + res = `${res}${p.text}`; }); return res; @@ -380,6 +460,98 @@ export function convertTextStyle( return res; } +// Canvas draws a text/richtext label background (see vrender's text-contribution-render: +// DefaultTextBackgroundRenderContribution) as a rounded rect derived from the graphic's +// bounds, filled before the text. The SVG converter previously dropped the `background` +// attribute entirely, so labels that rely on it (e.g. funnel labels with a semi-transparent +// block) exported without their background. Replicate the canvas rect here. +// +// The rect is read from the real graphic's AABBBounds (parent space) — the same object the +// canvas onlyTranslate branch uses — rather than recomputing bounds via vrender's global +// graphic registry, which is not reliably initialized inside this bundle. It is therefore +// emitted as a sibling of the text (parent space), not inside it. This is exact for +// translate-only labels (funnel/pie/bar labels); a rotated/scaled label would get an +// axis-aligned background instead of a rotated one, which is still far better than none. +// +// Only solid-color backgrounds are handled — an object/image `background` (used for +// background images) is out of scope and left to the text content as before. +export function convertTextBackground(attribute: any = {}, graphic: any): string { + const { background } = attribute; + + if (isNil(background) || typeof background !== "string") { + return ""; + } + + const bounds = graphic && graphic.AABBBounds; + if ( + !bounds || + typeof bounds.width !== "function" || + typeof bounds.height !== "function" + ) { + return ""; + } + + const x = bounds.x1; + const y = bounds.y1; + const width = bounds.width(); + const height = bounds.height(); + + if (!(width > 0) || !(height > 0)) { + return ""; + } + + // backgroundOpacity defaults to fillOpacity (canvas parity). + const backgroundOpacity = attribute.backgroundOpacity ?? attribute.fillOpacity; + const { backgroundCornerRadius } = attribute; + + const style: Record = { + fill: background, + stroke: "none", + "pointer-events": "none", + }; + if (!isNil(backgroundOpacity)) { + style["fill-opacity"] = backgroundOpacity; + } + + // Corner radius: uniform (number, or array of equal values) -> rx/ry; per-corner array -> path. + // Mirrors convertRectStyle so background rounding matches how the plugin renders rects. + const isUniform = + backgroundCornerRadius === +backgroundCornerRadius || + (Array.isArray(backgroundCornerRadius) && + backgroundCornerRadius.every((entry) => entry === backgroundCornerRadius[0])); + + if (Array.isArray(backgroundCornerRadius) && !isUniform) { + const roundedPath = parseCornerRadiusPath( + [ + { x, y }, + { x: x + width, y }, + { x: x + width, y: y + height }, + { x, y: y + height }, + ], + backgroundCornerRadius, + true + ); + return ``; + } + + const radius = Array.isArray(backgroundCornerRadius) + ? backgroundCornerRadius[0] + : backgroundCornerRadius; + if (radius) { + const r = Math.min(radius, width / 2, height / 2); + style.rx = r; + style.ry = r; + } + + return ``; +} + export function convertStyleToString(style: Record) { return Object.keys(style).reduce((res: string, key: string) => { if (isNil(style[key])) {