diff --git a/draftlogs/8051_fix.md b/draftlogs/8051_fix.md new file mode 100644 index 00000000000..339ec392f5b --- /dev/null +++ b/draftlogs/8051_fix.md @@ -0,0 +1,2 @@ +- Fix hover labels ignoring MathJax for text that is a single tex expression [[#8051](https://github.com/plotly/plotly.js/pull/8051)] +- Fix XSS via `javascript:` URLs in MathJax `\href` macros by sanitizing links in MathJax-rendered text [[#8051](https://github.com/plotly/plotly.js/pull/8051)] diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 92c7dd8b784..3e99e7d4318 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -230,35 +230,51 @@ 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; + + if (topY - tooltipSpacing < lastBottomY) { + d.offset = lastBottomY - topY + tooltipSpacing; + } else { + d.offset = 0; + } - lastBottomY = topY + d.by + d.offset; + lastBottomY = topY + d.by + d.offset; - if (i === opts.anchorIndex || 0) anchor = d.offset; - }) - .each(function (d) { - d.offset -= anchor; - }); + 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); + if (hoverText.mathjaxPromise) { + hoverText.mathjaxPromise.then(function () { + fixVerticalOverlap(); + alignHoverText(hoverLabel, rotateLabels, scaleX, scaleY); + + 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(); }; @@ -960,6 +976,21 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { if (!helpers.isUnifiedHover(hovermode)) { hoverAvoidOverlaps(hoverLabels, rotateLabels, fullLayout, hoverText.commonLabelBoundingBox); alignHoverText(hoverLabels, rotateLabels, fullLayout._invScaleX, fullLayout._invScaleY); + + 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); + + 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 +1033,10 @@ function hoverDataKey(d) { var EXTRA_STRING_REGEX = /([\s\S]*)<\/extra>/; +function getHoverTextBBox(gd, textSel) { + return getBoundingClientRect(gd, svgTextUtils.getMathOrTextNode(textSel)); +} + function createHoverText(hoverData, opts) { var gd = opts.gd; var fullLayout = gd._fullLayout; @@ -1014,6 +1049,8 @@ function createHoverText(hoverData, opts) { // Early exit if no labels are drawn if (hoverData.length === 0) return [[]]; + 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 +1127,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 +1150,206 @@ function createHoverText(hoverData, opts) { stroke: commonStroke }); + var commonLabelTex = svgTextUtils.isPureTex(String(t0)); + var onCommonLabelReady; + + if (commonLabelTex) { + mathjaxPromises.push( + new Promise(function (resolve) { + onCommonLabelReady = function () { + if (!ltext.node().parentNode) { + resolve(); + return; + } + positionCommonLabel(); + 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); + .call(svgTextUtils.convertToTspans, gd, onCommonLabelReady); - label.attr('transform', ''); + positionCommonLabel(); - var tbb = getBoundingClientRect(gd, ltext.node()); - var lx, ly; + function positionCommonLabel() { + label.attr('transform', ''); + svgTextUtils.positionText(ltext, 0, 0); + svgTextUtils.repositionMathGroup(ltext); - if (hovermode === 'x') { - var topsign = xa.side === 'top' ? '-' : ''; + var tbb = getHoverTextBBox(gd, ltext); + var lx, ly; - ltext - .attr('text-anchor', 'middle') - .call( - svgTextUtils.positionText, - 0, - xa.side === 'top' - ? outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD - : outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD - ); + if (hovermode === 'x') { + var topsign = xa.side === 'top' ? '-' : ''; - lx = xa._offset + (c0.x0 + c0.x1) / 2; - ly = ya._offset + (xa.side === 'top' ? 0 : ya._length); + ltext + .attr('text-anchor', 'middle') + .call( + svgTextUtils.positionText, + 0, + xa.side === 'top' + ? outerTop - tbb.bottom - HOVERARROWSIZE - HOVERTEXTPAD + : outerTop - tbb.top + HOVERARROWSIZE + HOVERTEXTPAD + ); - var halfWidth = tbb.width / 2 + HOVERTEXTPAD; + lx = xa._offset + (c0.x0 + c0.x1) / 2; + ly = ya._offset + (xa.side === 'top' ? 0 : ya._length); - var tooltipMidX = lx; - if (lx < halfWidth) { - tooltipMidX = halfWidth; - } else if (lx > fullLayout.width - halfWidth) { - tooltipMidX = fullLayout.width - halfWidth; - } + var halfWidth = tbb.width / 2 + 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' - ); + var tooltipMidX = lx; + if (lx < halfWidth) { + tooltipMidX = halfWidth; + } else if (lx > fullLayout.width - halfWidth) { + tooltipMidX = fullLayout.width - halfWidth; + } - 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; + 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 { - 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 +1621,25 @@ 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) { + if (g.node().parentNode) finalizePosition(); + resolve(); + } + }; + }) + ); + } // main label var tx = g @@ -1578,16 +1656,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 +1676,105 @@ 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 - }); + finalizePosition(); - 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'; + function finalizePosition() { + g.attr('transform', ''); + tx.call(svgTextUtils.positionText, 0, 0); + svgTextUtils.repositionMathGroup(tx); + if (hasName) { + tx2.call(svgTextUtils.positionText, 0, 0); + svgTextUtils.repositionMathGroup(tx2); } - 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'; + + 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); + tx2width = t2bb.width + 2 * HOVERTEXTPAD; + tx2height = t2bb.height + 2 * HOVERTEXTPAD; + } + + var tbb = getHoverTextBBox(gd, tx); + 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..bb1bece15fb 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -733,7 +733,7 @@ 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 + if(legendObj._inHover) s.attr('data-notex', svgTextUtils.isPureTex(s.text()) ? null : 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..21a0aa17beb 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -24,6 +24,98 @@ 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. + * + * @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; + +/** + * 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 + }); +} + +/** + * Removes an unsafe href/xlink:href from every MathJax's \href{url}{...} + * macro produced inside newSvg. + * + * @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(); @@ -113,6 +205,8 @@ exports.convertToTspans = function(_context, gd, _callback) { mathjaxGroup.node().appendChild(newSvg.node()); + sanitizeMathJaxLinks(newSvg); + // stitch the glyph defs if(_glyphDefs && _glyphDefs.node()) { newSvg.node().insertBefore(_glyphDefs.node().cloneNode(true), @@ -137,48 +231,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); @@ -189,6 +242,45 @@ 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. + * + * @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 textAnchor = _context.attr('text-anchor'); + var fontSize = parseInt(_context.node().style.fontSize, 10); + positionMathGroup(_context, svgClass, mathjaxGroup, newSvg, textAnchor, fontSize); +}; + +/** + * 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; + + 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(); +}; + // MathJax diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 21704fa8130..75c1c888d32 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; @@ -202,5 +205,425 @@ 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); + }); + + it('should strip a javascript: url from a tex \\href in an annotation', function(done) { + Plotly.newPlot(gd, { + data: [{x: [1, 2, 3], y: [1, 2, 3]}], + layout: { + annotations: [{ + x: 2, + y: 2, + showarrow: false, + text: '$\\href{javascript:alert(1)}{unsafe}$' + }] + } + }) + .then(function() { + var gd3 = d3Select(gd); + var link = gd3.select('.annotation-text-math-group a'); + + expect(link.size()).toBe(1, 'annotation link exists'); + expect(link.attr('href')).toBe(null, 'javascript: url stripped'); + }) + .then(done, done.fail); + }); + + it('should strip a javascript: url from a tex \\href in non-hover legend text', function(done) { + Plotly.newPlot(gd, { + data: [{ + x: [1, 2, 3], + y: [1, 2, 3], + name: '$\\href{javascript:alert(1)}{unsafe}$' + }], + layout: {showlegend: true} + }) + .then(function() { + var gd3 = d3Select(gd); + var link = gd3.select('.legendtext-math-group a'); + + expect(link.size()).toBe(1, 'legend link exists'); + expect(link.attr('href')).toBe(null, 'javascript: url stripped'); + }) + .then(done, done.fail); + }); + }); + + describe('Test hover tex rendering:', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + 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(); + } + + 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(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 + // 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 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'); + + 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: [{ + 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.node().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}, + // 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); + 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(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'); + expect(mathGroup.select('svg').attr('y')).not.toBe('NaN'); + }) + .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 + 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 still typeset a pure-tex unified hover row after hovering a plain-text row', function(done) { + Plotly.newPlot(gd, { + data: [{ + type: 'scatter', + mode: 'markers', + x: [1, 2], + y: [1, 2], + marker: {size: 20}, + hovertemplate: ['plain', '$\\alpha$'] + }], + layout: { + width: 500, + height: 400, + margin: {l: 0, t: 0, r: 0, b: 0}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]}, + hovermode: 'x unified', + hoverdistance: -1 + } + }) + .then(function() { + _hover(125, 300); + return delay(30)(); + }) + .then(function() { + expect(d3Select(gd).selectAll('g.legend [class*="legendtext-math-group"]').size()) + .toBe(0, 'plain-text row is not typeset'); + + _hover(250, 200); + return delay(30)(); + }) + .then(function() { + var mathGroup = d3Select(gd).select('g.legend [class*="legendtext-math-group"]'); + + expect(mathGroup.size()).toBe(1, 'pure-tex row typeset after the plain-text one'); + expect(mathGroup.attr('data-unformatted')).toBe('$\\alpha$'); + }) + .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); + }); }); });