Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 52 additions & 29 deletions src/snapshot/tosvg.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,39 @@ var Color = require('../components/color');
var xmlnsNamespaces = require('../constants/xmlns_namespaces');
var DOUBLEQUOTE_REGEX = /"/g;
var DUMMY_SUB = 'TOBESTRIPPED';
var DUMMY_REGEX = new RegExp('("' + DUMMY_SUB + ')|(' + DUMMY_SUB + '")', 'g');

// Match TOBESTRIPPED adjacent to either a literal " or its entity form ".
// XMLSerializer escapes inner double-quotes to " inside "-delimited
// attributes, and htmlEntityDecode now preserves that entity for safety.
const DUMMY_REGEX = new RegExp(`("${DUMMY_SUB})|(${DUMMY_SUB}")|("${DUMMY_SUB})|(${DUMMY_SUB}")`, 'g');

// Entities for & " ' - decoding these in attribute context is an XSS vector,
// so preserve them as-is. List includes named, decimal, and hex numeric forms.
const PRESERVED_ENTITIES = ['&', '&', '&', '"', '"', '"', ''', ''', '''];
// Entities for < and > - normalize to numeric so downstream passes treat them
// uniformly regardless of which form the serializer emitted.
const LESS_THAN_ENTITIES = ['&lt;', '&#60;', '&#x3c;'];
const GREATER_THAN_ENTITIES = ['&gt;', '&#62;', '&#x3e;'];

/**
* Decode non-structural entities to Unicode for non-browser SVG renderers,
* keeping & " ' < > entity-encoded to prevent attribute-context escape (XSS).
*
* @param s - serialized SVG string
* @returns entity-normalized SVG string
*/
function htmlEntityDecode(s) {
var hiddenDiv = d3.select('body').append('div').style({display: 'none'}).html('');
var replaced = s.replace(/(&[^;]*;)/gi, function(d) {
if(d === '&lt;') { return '&#60;'; } // special handling for brackets
if(d === '&rt;') { return '&#62;'; }
if(d.indexOf('<') !== -1 || d.indexOf('>') !== -1) { return ''; }
const hiddenDiv = d3.select('body').append('div').style({ display: 'none' }).html('');
const replaced = s.replace(/(&[^;]*;)/gi, (d) => {
const lower = d.toLowerCase();
if (PRESERVED_ENTITIES.includes(lower)) return d;
if (LESS_THAN_ENTITIES.includes(lower)) return '&#60;';
if (GREATER_THAN_ENTITIES.includes(lower)) return '&#62;';
if (d.includes('<') || d.includes('>')) return '';

return hiddenDiv.html(d).text(); // everything else, let the browser decode it to unicode
});
hiddenDiv.remove();

return replaced;
}

Expand Down Expand Up @@ -48,29 +70,29 @@ module.exports = function toSVG(gd, format, scale) {
// which notably add the contents of the gl-container
// into the main svg node
var basePlotModules = fullLayout._basePlotModules || [];
for(i = 0; i < basePlotModules.length; i++) {
for (i = 0; i < basePlotModules.length; i++) {
var _module = basePlotModules[i];

if(_module.toSVG) _module.toSVG(gd);
if (_module.toSVG) _module.toSVG(gd);
}

// add top items above them assumes everything in toppaper is either
// a group or a defs, and if it's empty (like hoverlayer) we can ignore it.
if(toppaper) {
if (toppaper) {
var nodes = toppaper.node().childNodes;

// make copy of nodes as childNodes prop gets mutated in loop below
var topGroups = Array.prototype.slice.call(nodes);

for(i = 0; i < topGroups.length; i++) {
for (i = 0; i < topGroups.length; i++) {
var topGroup = topGroups[i];

if(topGroup.childNodes.length) svg.node().appendChild(topGroup);
if (topGroup.childNodes.length) svg.node().appendChild(topGroup);
}
}

// remove draglayer for Adobe Illustrator compatibility
if(fullLayout._draggers) {
if (fullLayout._draggers) {
fullLayout._draggers.remove();
}

Expand All @@ -80,81 +102,82 @@ module.exports = function toSVG(gd, format, scale) {
svg.node().style.background = '';

svg.selectAll('text')
.attr({'data-unformatted': null, 'data-math': null})
.each(function() {
.attr({ 'data-unformatted': null, 'data-math': null })
.each(function () {
var txt = d3.select(this);

// hidden text is pre-formatting mathjax, the browser ignores it
// but in a static plot it's useless and it can confuse batik
// we've tried to standardize on display:none but make sure we still
// catch visibility:hidden if it ever arises
if(this.style.visibility === 'hidden' || this.style.display === 'none') {
if (this.style.visibility === 'hidden' || this.style.display === 'none') {
txt.remove();
return;
} else {
// clear other visibility/display values to default
// to not potentially confuse non-browser SVG implementations
txt.style({visibility: null, display: null});
txt.style({ visibility: null, display: null });
}

// Font family styles break things because of quotation marks,
// so we must remove them *after* the SVG DOM has been serialized
// to a string (browsers convert singles back)
var ff = this.style.fontFamily;
if(ff && ff.indexOf('"') !== -1) {
if (ff && ff.indexOf('"') !== -1) {
txt.style('font-family', ff.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB));
}

// Drop normal font-weight, font-style and font-variant to reduce the size
var fw = this.style.fontWeight;
if(fw && (fw === 'normal' || fw === '400')) { // font-weight 400 is similar to normal
if (fw && (fw === 'normal' || fw === '400')) {
// font-weight 400 is similar to normal
txt.style('font-weight', undefined);
}
var fs = this.style.fontStyle;
if(fs && fs === 'normal') {
if (fs && fs === 'normal') {
txt.style('font-style', undefined);
}
var fv = this.style.fontVariant;
if(fv && fv === 'normal') {
if (fv && fv === 'normal') {
txt.style('font-variant', undefined);
}
});

svg.selectAll('.gradient_filled,.pattern_filled').each(function() {
svg.selectAll('.gradient_filled,.pattern_filled').each(function () {
var pt = d3.select(this);

// similar to font family styles above,
// we must remove " after the SVG DOM has been serialized
var fill = this.style.fill;
if(fill && fill.indexOf('url(') !== -1) {
if (fill && fill.indexOf('url(') !== -1) {
pt.style('fill', fill.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB));
}

var stroke = this.style.stroke;
if(stroke && stroke.indexOf('url(') !== -1) {
if (stroke && stroke.indexOf('url(') !== -1) {
pt.style('stroke', stroke.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB));
}
});

if(format === 'pdf' || format === 'eps') {
if (format === 'pdf' || format === 'eps') {
// these formats make the extra line MathJax adds around symbols look super thick in some cases
// it looks better if this is removed entirely.
svg.selectAll('#MathJax_SVG_glyphs path')
.attr('stroke-width', 0);
svg.selectAll('#MathJax_SVG_glyphs path').attr('stroke-width', 0);
}

if(format === 'svg' && scale) {
if (format === 'svg' && scale) {
svg.attr('width', scale * width);
svg.attr('height', scale * height);
svg.attr('viewBox', '0 0 ' + width + ' ' + height);
}

var s = new window.XMLSerializer().serializeToString(svg.node());
// Decode numeric refs to Unicode so non-browser renderers (Batik, Illustrator) render them correctly.
s = htmlEntityDecode(s);
s = xmlEntityEncode(s);

// Fix quotations around font strings and gradient URLs
s = s.replace(DUMMY_REGEX, '\'');
s = s.replace(DUMMY_REGEX, "'");

return s;
};
Loading
Loading