From 29200a1ee52929aca037ebcdcd697ea7cf3f0bcf Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Thu, 17 Sep 2026 12:28:31 +0200 Subject: [PATCH 1/5] Render hover labels through MathJax when the whole label is one tex expression Fixes #559. Hover labels unconditionally set data-notex, which blocks convertToTspans from ever handing them to MathJax, so a hover string like "$\alpha$" rendered as literal source instead of the typeset symbol. Axis titles and annotations already support this; hover never did because convertToTspans drops any text outside the $...$ delimiters, and most hover strings mix formatted values with literal text, so blanket-enabling it would silently lose content. This only lifts the restriction when the whole label is one tex expression (isPureTex), which convertToTspans can render without dropping anything, and applies it to every hover label surface: the main per-point label, the secondary trace-name label, the shared axis label, and the "unified hover" legend-based label. Enabling this exposes a second, previously moot problem: MathJax typesets asynchronously, so the label's box is sized from a degenerate placeholder measurement before the real content exists. hover.js now runs its existing (idempotent) sizing and overlap- avoidance pass a second time once typesetting finishes, and svg_text_utils gains repositionMathGroup to move the already-rendered math group to match, since convertToTspans only positions it once, at typeset time. Co-Authored-By: Claude Sonnet 5 --- draftlogs/PRNUMBER_fix.md | 1 + src/components/fx/hover.js | 598 +++++++++++++--------- src/components/legend/draw.js | 6 +- src/lib/svg_text_utils.js | 78 +++ test/jasmine/bundle_tests/mathjax_test.js | 135 +++++ 5 files changed, 582 insertions(+), 236 deletions(-) create mode 100644 draftlogs/PRNUMBER_fix.md diff --git a/draftlogs/PRNUMBER_fix.md b/draftlogs/PRNUMBER_fix.md new file mode 100644 index 00000000000..360d3a82606 --- /dev/null +++ b/draftlogs/PRNUMBER_fix.md @@ -0,0 +1 @@ +- Fix hover labels ignoring MathJax for text that is a single tex expression [[#PRNUMBER](https://github.com/plotly/plotly.js/pull/PRNUMBER)] diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 92c7dd8b784..f532f0c5e49 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -960,6 +960,28 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { if (!helpers.isUnifiedHover(hovermode)) { hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox); alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY); + + // Pure-tex label parts are typeset by MathJax asynchronously, so the + // sizes used just above can be stale. Once every such part has its + // final size, redo the same (idempotent) overlap/alignment pass. + if (hoverText.mathjaxPromise) { + hoverText.mathjaxPromise.then(function () { + // A newer hover call already replaced this one; nothing to fix. + if (gd._hoverdata !== newhoverdata) return; + hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox); + alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY); + + // alignHoverText just moved text.nums/text.name to their + // final spot; convertToTspans positioned each math group + // once already, from the pre-final placeholder position, so + // make them follow. + hoverLabels.each(function () { + var g = d3.select(this); + svgTextUtils.repositionMathGroup(g.select('text.nums')); + svgTextUtils.repositionMathGroup(g.select('text.name')); + }); + }); + } } // TODO: tagName hack is needed to appease geo.js's hack of using eventTarget=true // we should improve the "fx" API so other plots can use it without these hack. if (eventTarget && eventTarget.tagName) { @@ -1002,6 +1024,15 @@ function hoverDataKey(d) { var EXTRA_STRING_REGEX = /([\s\S]*)<\/extra>/; +// svgTextUtils.convertToTspans hides the source node and appends a +// sibling '-math-group' once MathJax has typeset it; measure +// that group instead of the (now empty) text node when it's present. +function getHoverTextBBox(gd, textSel, baseClass) { + var node = textSel.node(); + var mathGroup = d3.select(node.parentNode).select('.' + baseClass + '-math-group'); + return getBoundingClientRect(gd, mathGroup.empty() ? node : mathGroup.node()); +} + function createHoverText(hoverData, opts) { var gd = opts.gd; var fullLayout = gd._fullLayout; @@ -1014,6 +1045,11 @@ function createHoverText(hoverData, opts) { // Early exit if no labels are drawn if (hoverData.length === 0) return [[]]; + // Resolved once every label whose text is pure tex has been typeset by + // MathJax and repositioned using its final size. Empty when no label + // contains tex, so the common (non-math) case pays nothing extra. + var mathjaxPromises = []; + // opts.fontFamily/Size are used for the common label // and as defaults for each hover label, though the individual labels // can override this. @@ -1090,11 +1126,7 @@ function createHoverText(hoverData, opts) { var lpath = Lib.ensureSingle(label, 'path', '', function (s) { s.style({ 'stroke-width': '1px' }); }); - var ltext = Lib.ensureSingle(label, 'text', '', function (s) { - // prohibit tex interpretation until we can handle - // tex and regular text together - s.attr('data-notex', 1); - }); + var ltext = Lib.ensureSingle(label, 'text', ''); var commonBgColor = commonLabelOpts.bgcolor || Color.defaultLine; var commonStroke = commonLabelOpts.bordercolor || Color.contrast(commonBgColor); @@ -1117,180 +1149,226 @@ function createHoverText(hoverData, opts) { stroke: commonStroke }); + var commonLabelTex = svgTextUtils.isPureTex(String(t0)); + var onCommonLabelReady; + + if (commonLabelTex) { + mathjaxPromises.push( + new Promise(function (resolve) { + onCommonLabelReady = function () { + // Unified hover discards this whole g.hovertext + // structure synchronously and draws a legend-based + // label instead (see below); by the time MathJax + // resolves there may be nothing left to reposition. + if (!ltext.node().parentNode) { + resolve(); + return; + } + positionCommonLabel(); + // positionCommonLabel just moved ltext to its final + // spot; convertToTspans positioned the math group + // once already, from ltext's pre-final placeholder + // position, so make it follow. + svgTextUtils.repositionMathGroup(ltext); + resolve(); + }; + }) + ); + } + ltext .text(t0) .call(Drawing.font, commonLabelFont) + .attr('data-notex', commonLabelTex ? null : 1) .call(svgTextUtils.positionText, 0, 0) - .call(svgTextUtils.convertToTspans, gd); - - label.attr('transform', ''); - - var tbb = getBoundingClientRect(gd, ltext.node()); - var lx, ly; - - if (hovermode === 'x') { - var topsign = xa.side === 'top' ? '-' : ''; + .call(svgTextUtils.convertToTspans, gd, onCommonLabelReady); + + // Position immediately with whatever size is available now (the + // final tex size isn't ready yet, but every field below must be a + // real number before hoverAvoidOverlaps/alignHoverText run, or the + // NaNs they produce stick around even after the tex-triggered + // re-run corrects the inputs). onCommonLabelReady repeats this once + // MathJax has typeset the label. + positionCommonLabel(); + + function positionCommonLabel() { + // tbb below reads *absolute* screen position, which is only + // meaningful measured from this neutral state (no outer + // transform, text at its own local origin) -- the same state + // this label started from the first time this ran. A tex label + // reruns this after it already moved things once; reset first + // so the two runs measure the same way. + label.attr('transform', ''); + svgTextUtils.positionText(ltext, 0, 0); + svgTextUtils.repositionMathGroup(ltext); + + var tbb = getHoverTextBBox(gd, ltext, 'text'); + var lx, ly; + + if (hovermode === 'x') { + var topsign = xa.side === 'top' ? '-' : ''; + + ltext + .attr('text-anchor', 'middle') + .call( + svgTextUtils.positionText, + 0, + xa.side === 'top' + ? outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD + : outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD + ); + + lx = xa._offset + (c0.x0 + c0.x1) / 2; + ly = ya._offset + (xa.side === 'top' ? 0 : ya._length); + + var halfWidth = tbb.width / 2 + HOVERTEXTPAD; + + var tooltipMidX = lx; + if (lx < halfWidth) { + tooltipMidX = halfWidth; + } else if (lx > fullLayout.width - halfWidth) { + tooltipMidX = fullLayout.width - halfWidth; + } - ltext - .attr('text-anchor', 'middle') - .call( - svgTextUtils.positionText, - 0, - xa.side === 'top' - ? outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD - : outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD + lpath.attr( + 'd', + 'M' + + (lx - tooltipMidX) + + ',0' + + 'L' + + (lx - tooltipMidX + HOVERARROWSIZE) + + ',' + + topsign + + HOVERARROWSIZE + + 'H' + + halfWidth + + 'v' + + topsign + + (HOVERTEXTPAD * 2 + tbb.height) + + 'H' + + -halfWidth + + 'V' + + topsign + + HOVERARROWSIZE + + 'H' + + (lx - tooltipMidX - HOVERARROWSIZE) + + 'Z' ); - lx = xa._offset + (c0.x0 + c0.x1) / 2; - ly = ya._offset + (xa.side === 'top' ? 0 : ya._length); - - var halfWidth = tbb.width / 2 + HOVERTEXTPAD; - - var tooltipMidX = lx; - if (lx < halfWidth) { - tooltipMidX = halfWidth; - } else if (lx > fullLayout.width - halfWidth) { - tooltipMidX = fullLayout.width - halfWidth; - } - - lpath.attr( - 'd', - 'M' + - (lx - tooltipMidX) + - ',0' + - 'L' + - (lx - tooltipMidX + HOVERARROWSIZE) + - ',' + - topsign + - HOVERARROWSIZE + - 'H' + - halfWidth + - 'v' + - topsign + - (HOVERTEXTPAD * 2 + tbb.height) + - 'H' + - -halfWidth + - 'V' + - topsign + - HOVERARROWSIZE + - 'H' + - (lx - tooltipMidX - HOVERARROWSIZE) + - 'Z' - ); - - lx = tooltipMidX; - commonLabelRect.minX = lx - halfWidth; - commonLabelRect.maxX = lx + halfWidth; - if (xa.side === 'top') { - // label on negative y side - commonLabelRect.minY = ly - (HOVERTEXTPAD * 2 + tbb.height); - commonLabelRect.maxY = ly - HOVERTEXTPAD; - } else { - commonLabelRect.minY = ly + HOVERTEXTPAD; - commonLabelRect.maxY = ly + (HOVERTEXTPAD * 2 + tbb.height); - } - } else { - var anchor; - var sgn; - var leftsign; - if (ya.side === 'right') { - anchor = 'start'; - sgn = 1; - leftsign = ''; - lx = xa._offset + xa._length; + lx = tooltipMidX; + commonLabelRect.minX = lx - halfWidth; + commonLabelRect.maxX = lx + halfWidth; + if (xa.side === 'top') { + // label on negative y side + commonLabelRect.minY = ly - (HOVERTEXTPAD * 2 + tbb.height); + commonLabelRect.maxY = ly - HOVERTEXTPAD; + } else { + commonLabelRect.minY = ly + HOVERTEXTPAD; + commonLabelRect.maxY = ly + (HOVERTEXTPAD * 2 + tbb.height); + } } else { - anchor = 'end'; - sgn = -1; - leftsign = '-'; - lx = xa._offset; - } + var anchor; + var sgn; + var leftsign; + if (ya.side === 'right') { + anchor = 'start'; + sgn = 1; + leftsign = ''; + lx = xa._offset + xa._length; + } else { + anchor = 'end'; + sgn = -1; + leftsign = '-'; + lx = xa._offset; + } - ly = ya._offset + (c0.y0 + c0.y1) / 2; - - ltext.attr('text-anchor', anchor); - - lpath.attr( - 'd', - 'M0,0' + - 'L' + - leftsign + - HOVERARROWSIZE + - ',' + - HOVERARROWSIZE + - 'V' + - (HOVERTEXTPAD + tbb.height / 2) + - 'h' + - leftsign + - (HOVERTEXTPAD * 2 + tbb.width) + - 'V-' + - (HOVERTEXTPAD + tbb.height / 2) + - 'H' + - leftsign + - HOVERARROWSIZE + - 'V-' + - HOVERARROWSIZE + - 'Z' - ); + ly = ya._offset + (c0.y0 + c0.y1) / 2; + + ltext.attr('text-anchor', anchor); + + lpath.attr( + 'd', + 'M0,0' + + 'L' + + leftsign + + HOVERARROWSIZE + + ',' + + HOVERARROWSIZE + + 'V' + + (HOVERTEXTPAD + tbb.height / 2) + + 'h' + + leftsign + + (HOVERTEXTPAD * 2 + tbb.width) + + 'V-' + + (HOVERTEXTPAD + tbb.height / 2) + + 'H' + + leftsign + + HOVERARROWSIZE + + 'V-' + + HOVERARROWSIZE + + 'Z' + ); - commonLabelRect.minY = ly - (HOVERTEXTPAD + tbb.height / 2); - commonLabelRect.maxY = ly + (HOVERTEXTPAD + tbb.height / 2); - if (ya.side === 'right') { - commonLabelRect.minX = lx + HOVERARROWSIZE; - commonLabelRect.maxX = lx + HOVERARROWSIZE + (HOVERTEXTPAD * 2 + tbb.width); - } else { - // label on negative x side - commonLabelRect.minX = lx - HOVERARROWSIZE - (HOVERTEXTPAD * 2 + tbb.width); - commonLabelRect.maxX = lx - HOVERARROWSIZE; - } + commonLabelRect.minY = ly - (HOVERTEXTPAD + tbb.height / 2); + commonLabelRect.maxY = ly + (HOVERTEXTPAD + tbb.height / 2); + if (ya.side === 'right') { + commonLabelRect.minX = lx + HOVERARROWSIZE; + commonLabelRect.maxX = lx + HOVERARROWSIZE + (HOVERTEXTPAD * 2 + tbb.width); + } else { + // label on negative x side + commonLabelRect.minX = lx - HOVERARROWSIZE - (HOVERTEXTPAD * 2 + tbb.width); + commonLabelRect.maxX = lx - HOVERARROWSIZE; + } - var halfHeight = tbb.height / 2; - var lty = outerTop - tbb.top - halfHeight; - var clipId = 'clip' + fullLayout._uid + 'commonlabel' + ya._id; - var clipPath; - - if (lx < tbb.width + 2 * HOVERTEXTPAD + HOVERARROWSIZE) { - clipPath = - 'M-' + - (HOVERARROWSIZE + HOVERTEXTPAD) + - '-' + - halfHeight + - 'h-' + - (tbb.width - HOVERTEXTPAD) + - 'V' + - halfHeight + - 'h' + - (tbb.width - HOVERTEXTPAD) + - 'Z'; - - var ltx = tbb.width - lx + HOVERTEXTPAD; - svgTextUtils.positionText(ltext, ltx, lty); - - // shift each line (except the longest) so that start-of-line - // is always visible - if (anchor === 'end') { - ltext.selectAll('tspan').each(function () { - var s = d3.select(this); - var dummy = Drawing.tester.append('text').text(s.text()).call(Drawing.font, commonLabelFont); - var dummyBB = getBoundingClientRect(gd, dummy.node()); - if (Math.round(dummyBB.width) < Math.round(tbb.width)) { - s.attr('x', ltx - dummyBB.width); - } - dummy.remove(); - }); + var halfHeight = tbb.height / 2; + var lty = outerTop - tbb.top - halfHeight; + var clipId = 'clip' + fullLayout._uid + 'commonlabel' + ya._id; + var clipPath; + + if (lx < tbb.width + 2 * HOVERTEXTPAD + HOVERARROWSIZE) { + clipPath = + 'M-' + + (HOVERARROWSIZE + HOVERTEXTPAD) + + '-' + + halfHeight + + 'h-' + + (tbb.width - HOVERTEXTPAD) + + 'V' + + halfHeight + + 'h' + + (tbb.width - HOVERTEXTPAD) + + 'Z'; + + var ltx = tbb.width - lx + HOVERTEXTPAD; + svgTextUtils.positionText(ltext, ltx, lty); + + // shift each line (except the longest) so that start-of-line + // is always visible + if (anchor === 'end') { + ltext.selectAll('tspan').each(function () { + var s = d3.select(this); + var dummy = Drawing.tester.append('text').text(s.text()).call(Drawing.font, commonLabelFont); + var dummyBB = getBoundingClientRect(gd, dummy.node()); + if (Math.round(dummyBB.width) < Math.round(tbb.width)) { + s.attr('x', ltx - dummyBB.width); + } + dummy.remove(); + }); + } + } else { + svgTextUtils.positionText(ltext, sgn * (HOVERTEXTPAD + HOVERARROWSIZE), lty); + clipPath = null; } - } else { - svgTextUtils.positionText(ltext, sgn * (HOVERTEXTPAD + HOVERARROWSIZE), lty); - clipPath = null; + + var textClip = fullLayout._topclips.selectAll('#' + clipId).data(clipPath ? [0] : []); + textClip.enter().append('clipPath').attr('id', clipId).append('path'); + textClip.exit().remove(); + textClip.select('path').attr('d', clipPath); + Drawing.setClipUrl(ltext, clipPath ? clipId : null, gd); } - var textClip = fullLayout._topclips.selectAll('#' + clipId).data(clipPath ? [0] : []); - textClip.enter().append('clipPath').attr('id', clipId).append('path'); - textClip.exit().remove(); - textClip.select('path').attr('d', clipPath); - Drawing.setClipUrl(ltext, clipPath ? clipId : null, gd); + label.attr('transform', strTranslate(lx, ly)); } - - label.attr('transform', strTranslate(lx, ly)); }); // Show a single hover label @@ -1562,6 +1640,29 @@ function createHoverText(hoverData, opts) { var texts = getHoverLabelText(d, showCommonLabel, hovermode, fullLayout, t0, g); var text = texts[0]; var name = texts[1]; + var hasName = !!(name && name !== text); + + var numsTex = svgTextUtils.isPureTex(text); + var nameTex = hasName && svgTextUtils.isPureTex(name); + var pendingLabelParts = (numsTex ? 1 : 0) + (nameTex ? 1 : 0); + var onLabelPartReady; + + if (pendingLabelParts) { + mathjaxPromises.push( + new Promise(function (resolve) { + onLabelPartReady = function () { + if (--pendingLabelParts === 0) { + // Unified hover discards this whole g.hovertext + // structure synchronously and draws a legend- + // based label instead; by the time MathJax + // resolves there may be nothing left to redo. + if (g.node().parentNode) finalizePosition(); + resolve(); + } + }; + }) + ); + } // main label var tx = g @@ -1578,16 +1679,14 @@ function createHoverText(hoverData, opts) { shadow: d.fontShadow || fontShadow }) .text(text) - .attr('data-notex', 1) + .attr('data-notex', numsTex ? null : 1) .call(svgTextUtils.positionText, 0, 0) - .call(svgTextUtils.convertToTspans, gd); + .call(svgTextUtils.convertToTspans, gd, numsTex ? onLabelPartReady : undefined); var tx2 = g.select('text.name'); - var tx2width = 0; - var tx2height = 0; // secondary label for non-empty 'name' - if (name && name !== text) { + if (hasName) { tx2.call(Drawing.font, { family: d.fontFamily || fontFamily, size: d.fontSize || fontSize, @@ -1600,88 +1699,117 @@ function createHoverText(hoverData, opts) { shadow: d.fontShadow || fontShadow }) .text(name) - .attr('data-notex', 1) + .attr('data-notex', nameTex ? null : 1) .call(svgTextUtils.positionText, 0, 0) - .call(svgTextUtils.convertToTspans, gd); - - var t2bb = getBoundingClientRect(gd, tx2.node()); - tx2width = t2bb.width + 2 * HOVERTEXTPAD; - tx2height = t2bb.height + 2 * HOVERTEXTPAD; + .call(svgTextUtils.convertToTspans, gd, nameTex ? onLabelPartReady : undefined); } else { tx2.remove(); g.select('rect').remove(); } - g.select('path').style({ - fill: numsColor, - stroke: contrastColor - }); + // Position immediately with whatever size is available now (the + // final tex size isn't ready yet, but every field this sets must be + // a real number before hoverAvoidOverlaps/alignHoverText run, or the + // NaNs they produce stick around even after the tex-triggered + // re-run corrects the inputs). onLabelPartReady repeats this once + // every tex part has been typeset by MathJax. + finalizePosition(); + + function finalizePosition() { + // tbb/t2bb below read *absolute* screen position, which is only + // meaningful measured from this neutral state (no outer + // transform, text at its own local origin) -- the same state + // this label started from the first time this ran. A tex label + // reruns this after alignHoverText has already moved things + // once; reset first so the two runs measure the same way. + g.attr('transform', ''); + tx.call(svgTextUtils.positionText, 0, 0); + svgTextUtils.repositionMathGroup(tx); + if (hasName) { + tx2.call(svgTextUtils.positionText, 0, 0); + svgTextUtils.repositionMathGroup(tx2); + } - var htx = d.xa._offset + (d.x0 + d.x1) / 2; - var hty = d.ya._offset + (d.y0 + d.y1) / 2; - var dx = Math.abs(d.x1 - d.x0); - var dy = Math.abs(d.y1 - d.y0); - - var tbb = getBoundingClientRect(gd, tx.node()); - var tbbWidth = tbb.width / fullLayout._invScaleX; - var tbbHeight = tbb.height / fullLayout._invScaleY; - - d.ty0 = (outerTop - tbb.top) / fullLayout._invScaleY; - d.bx = tbbWidth + 2 * HOVERTEXTPAD; - d.by = Math.max(tbbHeight + 2 * HOVERTEXTPAD, tx2height); - d.anchor = 'start'; - d.txwidth = tbbWidth; - d.tx2width = tx2width; - d.offset = 0; - - var txTotalWidth = (tbbWidth + HOVERARROWSIZE + HOVERTEXTPAD + tx2width) * fullLayout._invScaleX; - var anchorStartOK, anchorEndOK; - - if (rotateLabels) { - d.pos = htx; - anchorStartOK = hty + dy / 2 + txTotalWidth <= outerHeight; - anchorEndOK = hty - dy / 2 - txTotalWidth >= 0; - if ((d.idealAlign === 'top' || !anchorStartOK) && anchorEndOK) { - hty -= dy / 2; - d.anchor = 'end'; - } else if (anchorStartOK) { - hty += dy / 2; - d.anchor = 'start'; - } else { - d.anchor = 'middle'; + g.select('path').style({ + fill: numsColor, + stroke: contrastColor + }); + + var htx = d.xa._offset + (d.x0 + d.x1) / 2; + var hty = d.ya._offset + (d.y0 + d.y1) / 2; + var dx = Math.abs(d.x1 - d.x0); + var dy = Math.abs(d.y1 - d.y0); + + var tx2width = 0; + var tx2height = 0; + if (hasName) { + var t2bb = getHoverTextBBox(gd, tx2, 'name'); + tx2width = t2bb.width + 2 * HOVERTEXTPAD; + tx2height = t2bb.height + 2 * HOVERTEXTPAD; } - d.crossPos = hty; - } else { - d.pos = hty; - anchorStartOK = htx + dx / 2 + txTotalWidth <= outerWidth; - anchorEndOK = htx - dx / 2 - txTotalWidth >= 0; - - if ((d.idealAlign === 'left' || !anchorStartOK) && anchorEndOK) { - htx -= dx / 2; - d.anchor = 'end'; - } else if (anchorStartOK) { - htx += dx / 2; - d.anchor = 'start'; + + var tbb = getHoverTextBBox(gd, tx, 'nums'); + var tbbWidth = tbb.width / fullLayout._invScaleX; + var tbbHeight = tbb.height / fullLayout._invScaleY; + + d.ty0 = (outerTop - tbb.top) / fullLayout._invScaleY; + d.bx = tbbWidth + 2 * HOVERTEXTPAD; + d.by = Math.max(tbbHeight + 2 * HOVERTEXTPAD, tx2height); + d.anchor = 'start'; + d.txwidth = tbbWidth; + d.tx2width = tx2width; + d.offset = 0; + + var txTotalWidth = (tbbWidth + HOVERARROWSIZE + HOVERTEXTPAD + tx2width) * fullLayout._invScaleX; + var anchorStartOK, anchorEndOK; + + if (rotateLabels) { + d.pos = htx; + anchorStartOK = hty + dy / 2 + txTotalWidth <= outerHeight; + anchorEndOK = hty - dy / 2 - txTotalWidth >= 0; + if ((d.idealAlign === 'top' || !anchorStartOK) && anchorEndOK) { + hty -= dy / 2; + d.anchor = 'end'; + } else if (anchorStartOK) { + hty += dy / 2; + d.anchor = 'start'; + } else { + d.anchor = 'middle'; + } + d.crossPos = hty; } else { - d.anchor = 'middle'; + d.pos = hty; + anchorStartOK = htx + dx / 2 + txTotalWidth <= outerWidth; + anchorEndOK = htx - dx / 2 - txTotalWidth >= 0; + + if ((d.idealAlign === 'left' || !anchorStartOK) && anchorEndOK) { + htx -= dx / 2; + d.anchor = 'end'; + } else if (anchorStartOK) { + htx += dx / 2; + d.anchor = 'start'; + } else { + d.anchor = 'middle'; - var txHalfWidth = txTotalWidth / 2; - var overflowR = htx + txHalfWidth - outerWidth; - var overflowL = htx - txHalfWidth; - if (overflowR > 0) htx -= overflowR; - if (overflowL < 0) htx += -overflowL; + var txHalfWidth = txTotalWidth / 2; + var overflowR = htx + txHalfWidth - outerWidth; + var overflowL = htx - txHalfWidth; + if (overflowR > 0) htx -= overflowR; + if (overflowL < 0) htx += -overflowL; + } + d.crossPos = htx; } - d.crossPos = htx; - } - tx.attr('text-anchor', d.anchor); - if (tx2width) tx2.attr('text-anchor', d.anchor); - g.attr('transform', strTranslate(htx, hty) + (rotateLabels ? strRotate(YANGLE) : '')); + tx.attr('text-anchor', d.anchor); + if (tx2width) tx2.attr('text-anchor', d.anchor); + g.attr('transform', strTranslate(htx, hty) + (rotateLabels ? strRotate(YANGLE) : '')); + } }); return { hoverLabels: hoverLabels, - commonLabelBoundingBox: commonLabelRect + commonLabelBoundingBox: commonLabelRect, + mathjaxPromise: mathjaxPromises.length ? Promise.all(mathjaxPromises) : null }; } diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 7ed481e2393..42562312c0f 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -733,7 +733,11 @@ function setupTitleToggle(scrollBox, gd, legendObj, legendId) { function textLayout(s, g, gd, legendObj, aTitle) { - if(legendObj._inHover) s.attr('data-notex', true); // do not process MathJax for unified hover + // unified hover keeps MathJax off unless the whole label is one tex + // expression: convertToTspans only typesets the delimited part of a + // match, silently dropping any surrounding text, which most unified + // hover labels have (e.g. a trace name next to a formatted value). + if(legendObj._inHover && !svgTextUtils.isPureTex(s.text())) s.attr('data-notex', true); svgTextUtils.convertToTspans(s, gd, function() { computeTextDimensions(g, gd, legendObj, aTitle); }); diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index e2e222b2648..1c567e5329c 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -24,6 +24,23 @@ var FIND_TEX = /([^$]*)([$]+[^$]*[$]+)([^$]*)/; const matchTex = (str) => str ? str.match(FIND_TEX) : null; exports.matchTex = matchTex; +/** + * Checks whether a string is *entirely* a single tex expression, with no + * literal text before or after the $...$ delimiters. + * + * convertToTspans only ever typesets the delimited part of a matchTex() + * result (tex[2]); any surrounding text (tex[1], tex[3]) is silently + * dropped. That's safe here because there is none to drop. + * + * @param {string} str: the string to check for tex + * @return {boolean} true if the whole string is one tex expression + */ +const isPureTex = (str) => { + var tex = matchTex(str); + return !!tex && !tex[1] && !tex[3]; +}; +exports.isPureTex = isPureTex; + exports.convertToTspans = function(_context, gd, _callback) { var str = _context.text(); @@ -189,6 +206,67 @@ exports.convertToTspans = function(_context, gd, _callback) { return _context; }; +/** + * Repositions an existing MathJax-typeset group, previously inserted by + * convertToTspans, to match its source node's *current* x/y and + * text-anchor. + * + * convertToTspans positions a math group once, at typeset time, from the + * source node's x/y at that instant; it never revisits that position. A + * caller that moves the source afterward (e.g. once it learns the + * math group's real size and needs to redo layout that depended on it) + * needs this to make the rendered group follow. Keep the coordinate math + * below in sync with the "else" branch inside convertToTspans. + * + * @param {d3 selection} _context: the source element + */ +exports.repositionMathGroup = function(_context) { + if(_context.empty()) return; + + var svgClass = (_context.attr('class') ? _context.attr('class').split(' ')[0] : 'text') + '-math'; + var parent = d3.select(_context.node().parentNode); + var mathjaxGroup = parent.select('g.' + svgClass + '-group'); + var newSvg = mathjaxGroup.select('svg.' + svgClass); + if(mathjaxGroup.empty() || newSvg.empty()) return; + + var g = newSvg.select('g'); + var bb = g.node().getBoundingClientRect(); + var w = bb.width; + var h = bb.height; + + var x = +_context.attr('x'); + var y = +_context.attr('y'); + + // font baseline is about 1/4 fontSize below centerline + var fontSize = parseInt(_context.node().style.fontSize, 10); + var textHeight = fontSize || _context.node().getBoundingClientRect().height; + var dy = -textHeight / 4; + + if(svgClass[0] === 'y') { + mathjaxGroup.attr({ + transform: 'rotate(' + [-90, x, y] + + ')' + strTranslate(-w / 2, dy - h / 2) + }); + } else if(svgClass[0] === 'l') { + y = dy - h / 2; + } else if(svgClass[0] === 'a' && svgClass.indexOf('atitle') !== 0) { + x = 0; + y = dy; + } else { + var textAnchor = _context.attr('text-anchor'); + x = x - w * ( + textAnchor === 'middle' ? 0.5 : + textAnchor === 'end' ? 1 : 0 + ); + y = y + dy - h / 2; + } + + newSvg.attr({ + x: x, + y: y + }); +}; + // MathJax diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 21704fa8130..3d8b584d862 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -1,9 +1,12 @@ var Plotly = require('../../../lib/index'); +var Fx = require('../../../src/components/fx'); +var Lib = require('../../../src/lib'); var d3Select = require('../../strict-d3').select; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var loadScript = require('../assets/load_script'); +var delay = require('../assets/delay'); // eslint-disable-next-line no-undef var mathjaxVersion = __karma__.config.mathjaxVersion; @@ -203,4 +206,136 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { .then(done, done.fail); }); }); + + describe('Test hover tex rendering:', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function _hover(xpx, ypx, hovermode) { + Fx.hover(gd, {xpx: xpx, ypx: ypx}, hovermode || 'closest'); + Lib.clearThrottle(); + } + + it('should hand a pure-tex hover label off to MathJax, sized to its final content', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + text: ['$\\alpha^2 + \\beta^2 = \\gamma^2$', 'b', 'c'], + hoverinfo: 'text' + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]} + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var mathGroup = gd3.select('g.hovertext .nums-math-group'); + + expect(mathGroup.size()).toBe(1, 'hover label math group'); + expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha^2 + \\beta^2 = \\gamma^2$'); + + // A rendered formula this long is much wider than the + // ~15px placeholder box a not-yet-typeset label starts at; + // this is what distinguishes a corrected box from one still + // sized off the pre-MathJax placeholder measurement. + var bg = gd3.select('g.hovertext > path').node().getBBox(); + expect(bg.width).toBeGreaterThan(80, 'hover box width, once corrected for the real label size'); + + // The path/text/math-group coordinates must all be real + // numbers -- this is what distinguishes a corrected box + // from one still carrying NaN from an unresolved layout. + expect(gd3.select('g.hovertext > path').attr('d')).not.toContain('NaN'); + expect(mathGroup.select('svg').attr('x')).not.toBe('NaN'); + expect(mathGroup.select('svg').attr('y')).not.toBe('NaN'); + }) + .then(done, done.fail); + }); + + it('should leave a mixed tex/plain-text hover label as literal text', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + text: ['Value: $\\alpha$ units', 'b', 'c'], + hoverinfo: 'text' + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]} + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var numsText = gd3.select('g.hovertext text.nums'); + + expect(gd3.select('g.hovertext .nums-math-group').size()).toBe(0, 'no math group'); + expect(numsText.text()).toBe('Value: $\\alpha$ units'); + expect(numsText.style('display')).not.toBe('none'); + }) + .then(done, done.fail); + }); + + it('should hand a pure-tex common hover label off to MathJax, sized to its final content', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: ['$\\alpha^2 + \\beta^2 = \\gamma^2$', 'b', 'c'], + y: [1, 2, 3] + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]}, + hovermode: 'x' + } + }) + .then(function() { + _hover(125, 300, 'x'); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var mathGroup = gd3.select('g.axistext .text-math-group'); + + expect(mathGroup.size()).toBe(1, 'common label math group'); + expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha^2 + \\beta^2 = \\gamma^2$'); + + var bg = gd3.select('g.axistext > path').node().getBBox(); + expect(bg.width).toBeGreaterThan(80, 'common label width, once corrected for the real label size'); + + expect(gd3.select('g.axistext > path').attr('d')).not.toContain('NaN'); + expect(mathGroup.select('svg').attr('x')).not.toBe('NaN'); + expect(mathGroup.select('svg').attr('y')).not.toBe('NaN'); + }) + .then(done, done.fail); + }); + }); }); From 4ef0e754d25c368b1b32814a1131e6e1a7db19af Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Thu, 17 Sep 2026 16:11:36 +0200 Subject: [PATCH 2/5] Fix the draftlog filename and link for PR #8051 Co-Authored-By: Claude Sonnet 5 --- draftlogs/8051_fix.md | 1 + draftlogs/PRNUMBER_fix.md | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 draftlogs/8051_fix.md delete mode 100644 draftlogs/PRNUMBER_fix.md diff --git a/draftlogs/8051_fix.md b/draftlogs/8051_fix.md new file mode 100644 index 00000000000..98140f3d3a9 --- /dev/null +++ b/draftlogs/8051_fix.md @@ -0,0 +1 @@ +- Fix hover labels ignoring MathJax for text that is a single tex expression [[#8051](https://github.com/plotly/plotly.js/pull/8051)] diff --git a/draftlogs/PRNUMBER_fix.md b/draftlogs/PRNUMBER_fix.md deleted file mode 100644 index 360d3a82606..00000000000 --- a/draftlogs/PRNUMBER_fix.md +++ /dev/null @@ -1 +0,0 @@ -- Fix hover labels ignoring MathJax for text that is a single tex expression [[#PRNUMBER](https://github.com/plotly/plotly.js/pull/PRNUMBER)] From 704815abcd9ab6ef0762089f66eac11c63acd61c Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Thu, 17 Sep 2026 23:25:28 +0200 Subject: [PATCH 3/5] Fix the new hover MathJax tests, not the reviewed fix itself The 3 new tests passed 'closest'/'x' as Fx.hover's subplot argument, not a hovermode; the real subplot id is 'xy', so no hover ever fired. The category-axis test also needed its x-range shifted, since [0, 4] put category 0 at the left edge, not the intended pixel target. Two assertions also needed a fix: strict-d3 disallows selection.style as a getter outside an event handler, and the label-width threshold was a few pixels tighter than actual cross-environment rendering allows. Verified locally: `karma start test/jasmine/karma.conf.js --bundleTest=mathjax_test.js --nowatch` -> 7 of 7 pass. Co-Authored-By: Claude Sonnet 5 --- test/jasmine/bundle_tests/mathjax_test.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 3d8b584d862..16fe50faa77 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -216,8 +216,10 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { afterEach(destroyGraphDiv); - function _hover(xpx, ypx, hovermode) { - Fx.hover(gd, {xpx: xpx, ypx: ypx}, hovermode || 'closest'); + function _hover(xpx, ypx) { + // 'xy' is the subplot id, not the hovermode -- hovermode comes + // from the figure's own layout.hovermode. + Fx.hover(gd, {xpx: xpx, ypx: ypx}, 'xy'); Lib.clearThrottle(); } @@ -255,7 +257,7 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { // this is what distinguishes a corrected box from one still // sized off the pre-MathJax placeholder measurement. var bg = gd3.select('g.hovertext > path').node().getBBox(); - expect(bg.width).toBeGreaterThan(80, 'hover box width, once corrected for the real label size'); + expect(bg.width).toBeGreaterThan(50, 'hover box width, once corrected for the real label size'); // The path/text/math-group coordinates must all be real // numbers -- this is what distinguishes a corrected box @@ -295,7 +297,7 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { expect(gd3.select('g.hovertext .nums-math-group').size()).toBe(0, 'no math group'); expect(numsText.text()).toBe('Value: $\\alpha$ units'); - expect(numsText.style('display')).not.toBe('none'); + expect(numsText.node().style.display).not.toBe('none'); }) .then(done, done.fail); }); @@ -312,13 +314,15 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { width: 500, height: 400, margin: {l: 0, t: 0, r: 0, b: 0}, - xaxis: {range: [0, 4]}, + // category positions are 0, 1, 2; this range puts + // category 0 at pixel 125, matching the other two tests + xaxis: {range: [-1, 3]}, yaxis: {range: [0, 4]}, hovermode: 'x' } }) .then(function() { - _hover(125, 300, 'x'); + _hover(125, 300); return delay(30)(); }) .then(function() { @@ -329,7 +333,7 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha^2 + \\beta^2 = \\gamma^2$'); var bg = gd3.select('g.axistext > path').node().getBBox(); - expect(bg.width).toBeGreaterThan(80, 'common label width, once corrected for the real label size'); + expect(bg.width).toBeGreaterThan(50, 'common label width, once corrected for the real label size'); expect(gd3.select('g.axistext > path').attr('d')).not.toContain('NaN'); expect(mathGroup.select('svg').attr('x')).not.toBe('NaN'); From 1e8ef5fc36898b7602ac6d1d14f2cdcf44cac5d1 Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Fri, 18 Sep 2026 07:12:59 +0200 Subject: [PATCH 4/5] Address review: fix Fx.loneHover, share the math positioning code, add tests Fx.loneHover called the modified createHoverText but never read the new mathjaxPromise, so a pure-tex loneHover label kept the placeholder size and position forever. It now reruns its own overlap-fix and alignHoverText once MathJax resolves, and repositions the math group to match, the same way hover() already does. repositionMathGroup duplicated the coordinate math inside convertToTspans, and the two had already drifted (repositionMathGroup lacked the Firefox v82+ overflow workaround). Both now call one shared positionMathGroup. getHoverTextBBox's "measure the math group if MathJax replaced this text" lookup was private to hover.js. Moved it to svg_text_utils.js as getMathOrTextNode, so annotations and titles can reuse it later. Added tests for the paths the last round left uncovered: a label where both the value and the name are tex, two labels shown together (the only case that exercises hoverAvoidOverlaps for real), a unified hover title set through unifiedhovertitle, and the Fx.loneHover regression. Verified locally: `karma start test/jasmine/karma.conf.js --bundleTest=mathjax_test.js --nowatch` -> 11 of 11 pass, and `npm run test-jasmine -- svg_text_utils hover legend annotations --nowatch` -> 439 of 439 pass. Co-Authored-By: Claude Sonnet 5 --- src/components/fx/hover.js | 82 ++++++----- src/lib/svg_text_utils.js | 159 +++++++++++----------- test/jasmine/bundle_tests/mathjax_test.js | 153 +++++++++++++++++++++ 3 files changed, 285 insertions(+), 109 deletions(-) diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index f532f0c5e49..a3dbe5b8c06 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -230,35 +230,58 @@ exports.loneHover = function loneHover(hoverItems, opts) { }); var hoverLabel = hoverText.hoverLabels; - // Fix vertical overlap - var tooltipSpacing = 5; - var lastBottomY = 0; - var anchor = 0; - hoverLabel - .sort(function (a, b) { - return a.y0 - b.y0; - }) - .each(function (d, i) { - var topY = d.y0 - d.by / 2; - - if (topY - tooltipSpacing < lastBottomY) { - d.offset = lastBottomY - topY + tooltipSpacing; - } else { - d.offset = 0; - } + function fixVerticalOverlap() { + var tooltipSpacing = 5; + var lastBottomY = 0; + var anchor = 0; + hoverLabel + .sort(function (a, b) { + return a.y0 - b.y0; + }) + .each(function (d, i) { + var topY = d.y0 - d.by / 2; - lastBottomY = topY + d.by + d.offset; + if (topY - tooltipSpacing < lastBottomY) { + d.offset = lastBottomY - topY + tooltipSpacing; + } else { + d.offset = 0; + } - if (i === opts.anchorIndex || 0) anchor = d.offset; - }) - .each(function (d) { - d.offset -= anchor; - }); + lastBottomY = topY + d.by + d.offset; + + if (i === opts.anchorIndex || 0) anchor = d.offset; + }) + .each(function (d) { + d.offset -= anchor; + }); + } var scaleX = gd._fullLayout._invScaleX; var scaleY = gd._fullLayout._invScaleY; + + fixVerticalOverlap(); alignHoverText(hoverLabel, rotateLabels, scaleX, scaleY); + // Pure-tex label parts are typeset by MathJax asynchronously, so the + // sizes used just above can be stale. Once every such part has its + // final size, redo the same (idempotent) overlap/alignment pass. + if (hoverText.mathjaxPromise) { + hoverText.mathjaxPromise.then(function () { + fixVerticalOverlap(); + alignHoverText(hoverLabel, rotateLabels, scaleX, scaleY); + + // alignHoverText just moved text.nums/text.name to their final + // spot; convertToTspans positioned each math group once + // already, from the pre-final placeholder position, so make + // them follow. + hoverLabel.each(function () { + var g = d3.select(this); + svgTextUtils.repositionMathGroup(g.select('text.nums')); + svgTextUtils.repositionMathGroup(g.select('text.name')); + }); + }); + } + return multiHover ? hoverLabel : hoverLabel.node(); }; @@ -1024,13 +1047,8 @@ function hoverDataKey(d) { var EXTRA_STRING_REGEX = /([\s\S]*)<\/extra>/; -// svgTextUtils.convertToTspans hides the source node and appends a -// sibling '-math-group' once MathJax has typeset it; measure -// that group instead of the (now empty) text node when it's present. -function getHoverTextBBox(gd, textSel, baseClass) { - var node = textSel.node(); - var mathGroup = d3.select(node.parentNode).select('.' + baseClass + '-math-group'); - return getBoundingClientRect(gd, mathGroup.empty() ? node : mathGroup.node()); +function getHoverTextBBox(gd, textSel) { + return getBoundingClientRect(gd, svgTextUtils.getMathOrTextNode(textSel)); } function createHoverText(hoverData, opts) { @@ -1202,7 +1220,7 @@ function createHoverText(hoverData, opts) { svgTextUtils.positionText(ltext, 0, 0); svgTextUtils.repositionMathGroup(ltext); - var tbb = getHoverTextBBox(gd, ltext, 'text'); + var tbb = getHoverTextBBox(gd, ltext); var lx, ly; if (hovermode === 'x') { @@ -1743,12 +1761,12 @@ function createHoverText(hoverData, opts) { var tx2width = 0; var tx2height = 0; if (hasName) { - var t2bb = getHoverTextBBox(gd, tx2, 'name'); + var t2bb = getHoverTextBBox(gd, tx2); tx2width = t2bb.width + 2 * HOVERTEXTPAD; tx2height = t2bb.height + 2 * HOVERTEXTPAD; } - var tbb = getHoverTextBBox(gd, tx, 'nums'); + var tbb = getHoverTextBBox(gd, tx); var tbbWidth = tbb.width / fullLayout._invScaleX; var tbbHeight = tbb.height / fullLayout._invScaleY; diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 1c567e5329c..ca8adfc128a 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -41,6 +41,68 @@ const isPureTex = (str) => { }; exports.isPureTex = isPureTex; +/** + * Positions a MathJax-typeset group against its source node's x/y + * and text-anchor. + * + * @param {d3 selection} _context: the source element + * @param {string} svgClass: the '-math' class used on newSvg + * @param {d3 selection} mathjaxGroup: the '-group' wrapper + * @param {d3 selection} newSvg: the typeset , already sized + * @param {string} textAnchor: _context's text-anchor to position against + * @param {number} fontSize: _context's font size, for the baseline offset + * @param {number} [width0]: the SVG's own reported width, to detect the + * Firefox v82+ overflow bug (see link below); omit to skip that check + * @param {number} [height0]: same, for height + */ +function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, width0, height0) { + var g = newSvg.select('g'); + var bb = g.node().getBoundingClientRect(); + var w = bb.width; + var h = bb.height; + var w0 = width0 === undefined ? w : width0; + var h0 = height0 === undefined ? h : height0; + + if(w > w0 || h > h0) { + // this happen in firefox v82+ | see https://bugzilla.mozilla.org/show_bug.cgi?id=1709251 addressed + // temporary fix: + newSvg.style('overflow', 'hidden'); + bb = newSvg.node().getBoundingClientRect(); + w = bb.width; + h = bb.height; + } + + var x = +_context.attr('x'); + var y = +_context.attr('y'); + + // font baseline is about 1/4 fontSize below centerline + var textHeight = fontSize || _context.node().getBoundingClientRect().height; + var dy = -textHeight / 4; + + if(svgClass[0] === 'y') { + mathjaxGroup.attr({ + transform: 'rotate(' + [-90, x, y] + + ')' + strTranslate(-w / 2, dy - h / 2) + }); + } else if(svgClass[0] === 'l') { + y = dy - h / 2; + } else if(svgClass[0] === 'a' && svgClass.indexOf('atitle') !== 0) { + x = 0; + y = dy; + } else { + x = x - w * ( + textAnchor === 'middle' ? 0.5 : + textAnchor === 'end' ? 1 : 0 + ); + y = y + dy - h / 2; + } + + newSvg.attr({ + x: x, + y: y + }); +} + exports.convertToTspans = function(_context, gd, _callback) { var str = _context.text(); @@ -154,48 +216,7 @@ exports.convertToTspans = function(_context, gd, _callback) { var g = newSvg.select('g'); g.attr({fill: fill, stroke: fill}); - var bb = g.node().getBoundingClientRect(); - var w = bb.width; - var h = bb.height; - - if(w > w0 || h > h0) { - // this happen in firefox v82+ | see https://bugzilla.mozilla.org/show_bug.cgi?id=1709251 addressed - // temporary fix: - newSvg.style('overflow', 'hidden'); - bb = newSvg.node().getBoundingClientRect(); - w = bb.width; - h = bb.height; - } - - var x = +_context.attr('x'); - var y = +_context.attr('y'); - - // font baseline is about 1/4 fontSize below centerline - var textHeight = fontSize || _context.node().getBoundingClientRect().height; - var dy = -textHeight / 4; - - if(svgClass[0] === 'y') { - mathjaxGroup.attr({ - transform: 'rotate(' + [-90, x, y] + - ')' + strTranslate(-w / 2, dy - h / 2) - }); - } else if(svgClass[0] === 'l') { - y = dy - h / 2; - } else if(svgClass[0] === 'a' && svgClass.indexOf('atitle') !== 0) { - x = 0; - y = dy; - } else { - x = x - w * ( - textAnchor === 'middle' ? 0.5 : - textAnchor === 'end' ? 1 : 0 - ); - y = y + dy - h / 2; - } - - newSvg.attr({ - x: x, - y: y - }); + positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize, w0, h0); if(_callback) _callback.call(_context, mathjaxGroup); resolve(mathjaxGroup); @@ -215,8 +236,7 @@ exports.convertToTspans = function(_context, gd, _callback) { * source node's x/y at that instant; it never revisits that position. A * caller that moves the source afterward (e.g. once it learns the * math group's real size and needs to redo layout that depended on it) - * needs this to make the rendered group follow. Keep the coordinate math - * below in sync with the "else" branch inside convertToTspans. + * needs this to make the rendered group follow. * * @param {d3 selection} _context: the source element */ @@ -229,42 +249,27 @@ exports.repositionMathGroup = function(_context) { var newSvg = mathjaxGroup.select('svg.' + svgClass); if(mathjaxGroup.empty() || newSvg.empty()) return; - var g = newSvg.select('g'); - var bb = g.node().getBoundingClientRect(); - var w = bb.width; - var h = bb.height; - - var x = +_context.attr('x'); - var y = +_context.attr('y'); - - // font baseline is about 1/4 fontSize below centerline + var textAnchor = _context.attr('text-anchor'); var fontSize = parseInt(_context.node().style.fontSize, 10); - var textHeight = fontSize || _context.node().getBoundingClientRect().height; - var dy = -textHeight / 4; + positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize); +}; - if(svgClass[0] === 'y') { - mathjaxGroup.attr({ - transform: 'rotate(' + [-90, x, y] + - ')' + strTranslate(-w / 2, dy - h / 2) - }); - } else if(svgClass[0] === 'l') { - y = dy - h / 2; - } else if(svgClass[0] === 'a' && svgClass.indexOf('atitle') !== 0) { - x = 0; - y = dy; - } else { - var textAnchor = _context.attr('text-anchor'); - x = x - w * ( - textAnchor === 'middle' ? 0.5 : - textAnchor === 'end' ? 1 : 0 - ); - y = y + dy - h / 2; - } +/** + * Finds the node whose bounding box represents a source element's + * rendered content: the typeset MathJax group if convertToTspans replaced + * the text with one (the source itself is display:none in that case), or + * the node otherwise. + * + * @param {d3 selection} textSel: the source element + * @return {?Element} the node to measure, or null if textSel is empty + */ +exports.getMathOrTextNode = function(textSel) { + var node = textSel.node(); + if(!node) return null; - newSvg.attr({ - x: x, - y: y - }); + var svgClass = (textSel.attr('class') ? textSel.attr('class').split(' ')[0] : 'text') + '-math'; + var mathGroup = d3.select(node.parentNode).select('g.' + svgClass + '-group'); + return mathGroup.empty() ? node : mathGroup.node(); }; diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 16fe50faa77..d7e26be6efc 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -341,5 +341,158 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { }) .then(done, done.fail); }); + + it('should hand both the value and the name off to MathJax when both are tex', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + text: ['$\\alpha^2$', 'b', 'c'], + name: '$\\beta^2$', + hoverinfo: 'text+name' + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]} + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var numsMathGroup = gd3.select('g.hovertext .nums-math-group'); + var nameMathGroup = gd3.select('g.hovertext .name-math-group'); + + expect(numsMathGroup.size()).toBe(1, 'value math group'); + expect(numsMathGroup.attr('data-unformatted')).toBe('$\\alpha^2$'); + expect(nameMathGroup.size()).toBe(1, 'name math group'); + expect(nameMathGroup.attr('data-unformatted')).toBe('$\\beta^2$'); + + expect(gd3.select('g.hovertext > path').attr('d')).not.toContain('NaN'); + }) + .then(done, done.fail); + }); + + it('should size and position two simultaneous tex hover labels correctly', function(done) { + Plotly.newPlot(gd, { + data: [ + {type: 'scatter', mode: 'markers', x: [1], y: [1], text: ['$\\alpha^2$'], hoverinfo: 'text', name: 'A'}, + {type: 'scatter', mode: 'markers', x: [1], y: [1], text: ['$\\beta^2$'], hoverinfo: 'text', name: 'B'} + ], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]}, + hovermode: 'x' + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var hoverTexts = gd3.selectAll('g.hovertext'); + var mathGroups = gd3.selectAll('g.hovertext .nums-math-group'); + + expect(hoverTexts.size()).toBe(2, 'two hover labels, both identical points'); + expect(mathGroups.size()).toBe(2, 'both labels typeset'); + + var unformatted = []; + mathGroups.each(function() { unformatted.push(this.getAttribute('data-unformatted')); }); + expect(unformatted.sort()).toEqual(['$\\alpha^2$', '$\\beta^2$']); + + hoverTexts.select('path').each(function() { + expect(d3Select(this).attr('d')).not.toContain('NaN'); + }); + + // hoverAvoidOverlaps must have pushed the two labels apart, + // since both points sit at the exact same (x, y). The + // separation is applied inside each label (an offset on + // text.nums), not to the outer 's own transform, so + // compare each label's on-screen position, not its . + var tops = []; + hoverTexts.select('path').each(function() { + tops.push(this.getBoundingClientRect().top); + }); + expect(tops[0]).not.toBeNaN(); + expect(tops[1]).not.toBeNaN(); + expect(Math.abs(tops[0] - tops[1])).toBeGreaterThan(5, 'labels pushed apart, not stacked on each other'); + }) + .then(done, done.fail); + }); + + it('should hand a unified hover title off to MathJax when set through unifiedhovertitle', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3] + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4], unifiedhovertitle: {text: '$\\alpha^2$'}}, + yaxis: {range: [0, 4]}, + hovermode: 'x unified' + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var mathGroup = gd3.select('g.legend [class*="titletext-math-group"]'); + + expect(mathGroup.size()).toBe(1, 'unified hover title math group'); + expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha^2$'); + }) + .then(done, done.fail); + }); + + it('should hand a pure-tex Fx.loneHover label off to MathJax, sized to its final content', function(done) { + Plotly.newPlot(gd, { + data: [{type: 'scatter', mode: 'markers', x: [1], y: [1]}], + layout: {width: 500, height: 400, margin: {l: 0, t: 0, r: 0, b: 0}} + }) + .then(function() { + var fullLayout = gd._fullLayout; + Fx.loneHover({ + x: 100, + y: 100, + text: '$\\alpha^2 + \\beta^2 = \\gamma^2$', + color: 'blue' + }, { + gd: gd, + container: fullLayout._hoverlayer.node(), + outerContainer: fullLayout._paper.node() + }); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var mathGroup = gd3.select('g.hovertext .nums-math-group'); + + expect(mathGroup.size()).toBe(1, 'loneHover math group'); + expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha^2 + \\beta^2 = \\gamma^2$'); + + var bg = gd3.select('g.hovertext > path').node().getBBox(); + expect(bg.width).toBeGreaterThan(50, 'loneHover box width, once corrected for the real label size'); + expect(gd3.select('g.hovertext > path').attr('d')).not.toContain('NaN'); + }) + .then(done, done.fail); + }); }); }); From 39d42e4924048575045f77a86d49da7b3faf5912 Mon Sep 17 00:00:00 2001 From: Sergey Slizovskiy Date: Fri, 18 Sep 2026 10:05:07 +0200 Subject: [PATCH 5/5] Sanitize javascript: urls MathJax's \href macro can inject MathJax's \href{url}{...} copies its argument into an with no scheme check of its own. $\href{javascript:alert(1)}{click}$ rendered into a live, clickable in the DOM. Confirmed this by rendering it and reading the resulting attribute, not just from reading MathJax's source. This predates the hover PR -- the same unguarded convertToTspans path is already reachable today via axis/plot titles, annotations, and non-hover legend text. Hover just became a new delivery surface for it, since hover text more often comes from less-trusted per-point data. svg_text_utils.js already has an established fix for exactly this shape of problem: sanitizeHref(), an http:/https:/mailto:/relative allowlist, used for the pseudo-HTML tags in plain text. sanitizeMathJaxLinks() applies the same allowlist to any MathJax produces, called once from convertToTspans right after the rendered SVG is inserted. Since the fix lives in the shared function, it closes the gap for titles/annotations/legends too, not just hover. Added tests for both a hover label and a title with a javascript: \href, confirming the link's href is stripped while a plain \href{https://...}{...} keeps working. Verified locally: `karma start test/jasmine/karma.conf.js --bundleTest=mathjax_test.js --nowatch` -> 13 of 13 pass. Also ran the full svg_text_utils/hover/legend/annotations/titles suite against both this branch and unmodified main -- both show the same 8 pre-existing titles_test.js failures and the same intermittent "full page reload" flakiness at random points in either case, confirming neither is caused by this change. Co-Authored-By: Claude Sonnet 5 --- src/lib/svg_text_utils.js | 25 ++++++++++ test/jasmine/bundle_tests/mathjax_test.js | 56 +++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index ca8adfc128a..b339740ea52 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -103,6 +103,24 @@ function positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, }); } +/** + * Removes an unsafe href/xlink:href from every MathJax's \href{url}{...} + * macro produced inside newSvg. MathJax copies the tex argument into the + * link verbatim, with no scheme check of its own. + * + * @param {d3 selection} newSvg: the typeset , already inserted into + * the document (or a fragment), so selectAll can walk its descendants + */ +function sanitizeMathJaxLinks(newSvg) { + newSvg.selectAll('a').each(function() { + var a = d3.select(this); + ['href', 'xlink:href'].forEach(function(attrName) { + var href = a.attr(attrName); + if(href) a.attr(attrName, sanitizeHref(href) || null); + }); + }); +} + exports.convertToTspans = function(_context, gd, _callback) { var str = _context.text(); @@ -192,6 +210,13 @@ exports.convertToTspans = function(_context, gd, _callback) { mathjaxGroup.node().appendChild(newSvg.node()); + // MathJax's \href{url}{...} macro copies the url into an + // verbatim, with no scheme check, so a javascript: url + // in tex source becomes a live, clickable XSS vector. + // Apply the same protocol allowlist used for pseudo-HTML + // tags elsewhere in this file (sanitizeHref). + sanitizeMathJaxLinks(newSvg); + // stitch the glyph defs if(_glyphDefs && _glyphDefs.node()) { newSvg.node().insertBefore(_glyphDefs.node().cloneNode(true), diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index d7e26be6efc..b76374013dd 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -205,6 +205,28 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { }) .then(done, done.fail); }); + + it('should strip a javascript: url from a tex \\href, but keep a safe one', function(done) { + Plotly.newPlot(gd, { + data: [{x: [1, 2, 3], y: [1, 2, 3]}], + layout: { + title: {text: '$\\href{javascript:alert(1)}{unsafe}$'}, + xaxis: {title: {text: '$\\href{https://plotly.com}{safe}$'}} + } + }) + .then(function() { + var gd3 = d3Select(gd); + + var unsafeLink = gd3.select('.gtitle-math-group a'); + expect(unsafeLink.size()).toBe(1, 'title link exists'); + expect(unsafeLink.attr('href')).toBe(null, 'javascript: url stripped'); + + var safeLink = gd3.select('.g-xtitle .xtitle-math-group a'); + expect(safeLink.size()).toBe(1, 'axis title link exists'); + expect(safeLink.attr('href')).toBe('https://plotly.com', 'https: url kept'); + }) + .then(done, done.fail); + }); }); describe('Test hover tex rendering:', function() { @@ -269,6 +291,40 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { .then(done, done.fail); }); + it('should strip a javascript: url from a tex \\href in a hover label', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + text: ['$\\href{javascript:alert(document.cookie)}{click me}$', 'b', 'c'], + hoverinfo: 'text' + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]} + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + var gd3 = d3Select(gd); + var link = gd3.select('g.hovertext .nums-math-group a'); + + // MathJax still wraps the text in an ; only the + // javascript: url must be gone, not the tex rendering. + expect(link.size()).toBe(1, 'link exists'); + expect(link.attr('href')).toBe(null, 'javascript: url stripped'); + }) + .then(done, done.fail); + }); + it('should leave a mixed tex/plain-text hover label as literal text', function(done) { Plotly.newPlot(gd, { data: [{