1

I have tried to follow the advice here:

SVG to Canvas with d3.js

as well as the this npm module to export my c3 (which is based on d3) plots to an image file - .png for now.

In the browser, the image file looks like this:

enter image description here

The save-svg-as-png node module, however, produces this:

enter image description here

The script from the above mentioned SO post produces the following in a new tab:

enter image description here

As you can see, the paths are closed and then filled in, as though the .css is being ignored.

Here is the code to produce the plots:

# plotMultiline.js
import * as c3 from 'c3';
import * as d3 from 'd3';
import { saveSvgAsPng } from 'save-svg-as-png';
import createImageFromSVG from './createImageFromSVG';

const plotMultiline = (
  chartID, dataToPlot,
  headingsAndLabels,
  xHeading,
  nTicks,
  xRange,
  xLines = [],
  title,
  xAxisTitle,
  note,
  yTitle = 'Frequency of occurrence',
  inset = null,
) => {
  d3.select('body').append('div')
    .attr('id', chartID);

  const yDatas = Object.entries(headingsAndLabels).map(([columnName, newLabel]) =>
    [newLabel, ...dataToPlot.map(d => d[columnName])]);
  const firstKey = Object.keys(dataToPlot[0])[0];
  const secondKey = Object.keys(dataToPlot[0])[1];
  const xMin = +dataToPlot[0][firstKey];
  const xMax = +[...dataToPlot].pop()[secondKey];
  const xTickValuesAll = [...dataToPlot.map(d => +d[firstKey])];
  const nXTickValuesAll = xTickValuesAll.length;
  const xTickValuesIndices =
    [...Array(nTicks).keys()].map(d => d * Math.ceil(nXTickValuesAll / nTicks))
      .filter(d => d <= nXTickValuesAll - 1);

  let xTickValues = [];
  if (nTicks) {
    if (typeof nTicks === 'number') {
      xTickValues = [...xTickValuesIndices.map(i => +xTickValuesAll[i]), xMax];
    } else if (nTicks === 'integer') {
      xTickValues = [...xTickValuesAll, xMax].filter(d => Math.round(d) === d);
    }
  } else {
    xTickValues = [...xTickValuesAll, xMax];
  }
  const rightPadding = (xTickValues[1] - xTickValues[0]) / 5;

  const chart = c3.generate({
    bindto: `#${chartID}`,
    title: {
      text: title,
    },
    point: {
      show: false,
    },
    size: {
      width: 960,
      height: 500,
    },
    padding: {
      bottom: 20,
      top: 20,
    },
    data: {
      x: xHeading,
      columns: yDatas,
    },
    legend: {
      position: 'inset',
      inset,
    },
    axis: {
      x: {
        tick: {
          outer: false,
          values: xTickValues,
        },
        min: xMin,
        max: xMax,
        padding: { left: 0, right: rightPadding },
        label: {
          text: xAxisTitle || xHeading,
          position: 'outer-center',
        },
        height: 50,
      },
      y: {
        padding: { top: 0, bottom: 0 },
        label: {
          text: yTitle,
          position: 'outer-middle',
        },
      },
    },
    grid: {
      x: {
        show: true,
        lines: xLines,
      },
      y: {
        show: true,
      },
    },
  });

  d3.select(`#${chartID} svg`).attr('id', `svg-${chartID}`);

  if (note) {
    d3.select(`#${chartID} svg`).append('text')
      .attr('x', 630)
      .attr('y', 485)
      .classed('note', true)
      .text(note);
  }

  if (xRange) {
    const xRangeMin = xRange[0];
    const xRangeMax = xRange[1];

    chart.axis.range({
      min: {
        x: xRangeMin,
      },
      max: {
        x: xRangeMax,
      },
    });
  }

  setTimeout(() => {
    d3.select(`#${chartID}`)
      .append('button')
      .on('click', () => saveSvgAsPng(d3.select(`#svg-${chartID}`)[0]['0'], `#svg-${chartID}.png`))
      .classed('btn btn-success', true)
      .attr('id', 'button-library');

    d3.select(`#${chartID}`)
      .append('button')
      .on('click', () => createImageFromSVG(`#svg-${chartID}`))
      .classed('btn btn-success', true)
      .attr('id', 'button-so-script');
  }, 1000);
};

export default plotMultiline;

and (copied from the above-mentioned SO post):

# createImageFromSVG
import * as d3 from 'd3';

const createImageFromSVG = (selectorForSVG) => {
  // get styles from all required stylesheets
  // http://www.coffeegnome.net/converting-svg-to-png-with-canvg/
  let style = '\n';
  for (let i = 0; i < document.styleSheets.length; i++) {
    const sheet = document.styleSheets[i];

    if (sheet.href) {
      const { rules } = sheet;
      if (rules) {
        for (let j = 0; j < rules.length; j++) {
          style += (`${rules[j].cssText}\n`);
        }
      }
    }
  }

  const svg = d3.select(selectorForSVG);
  const img = new Image();
  const serializer = new XMLSerializer();

  // prepend style to svg
  svg.insert('defs', ':first-child');
  d3.select('svg defs')
    .append('style')
    .attr('type', 'text/css')
    .html(style);

  // generate IMG in new tab
  const svgStr = serializer.serializeToString(svg.node());
  img.src = `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;

  const popUp = window.open();
  if (popUp) {
    popUp.document.write(`<img src="${img.src}"/>`);
  }
};

export default createImageFromSVG;

I have also uploaded a sample project with this code to github:

https://github.com/shafiquejamal/export-svg-to-png

Could someone advise on how to make the exported file look like what is rendered in the browser? Thanks!

UPDATE #1: Following https://stackoverflow.com/users/3702797/kaiido's suggestion below, in plotMultiline.js I changed

d3.select(`#${chartID} svg`).attr('id', `svg-${chartID}`)

to

d3.select(`#${chartID} svg`).attr('id', `svg-${chartID}`)
.classed('c3', true);

and then saveSvgAsPng generates the following image file:

enter image description here

This fixes the closed-shape fill issue, but the background is actually transparent, not white, as you can see from the following screenshot:

enter image description here

But this is actually good enough for my purposes.

Shafique Jamal
  • 1,550
  • 3
  • 21
  • 45

1 Answers1

2

This is because c3 sets some rules from the parent div container, which has a class="c3" attribute.

In particular, you will find a

.c3 path, .c3 line {
    fill: none;
    stroke: rgb(0, 0, 0);
}

rule.
This rule won't match anymore when you will convert your svg to a standalone.

To workaround this, you might simply set this class on the parent svg node instead.

svg.classList.add('c3');

But you will loose the

.c3 svg {
    font: 10px sans-serif;
    -webkit-tap-highlight-color: transparent;
}

rule. So you might have to set it yourself, and convert it to e.g .c3 svg, svg.c3 {...

Alternatively, you could getComputedStyle all the nodes of your svg, and filter out the defaults ones, but that would still be a lot of work to do for the browser...

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • thank you for your suggestion. I found that `svg.classList.add` did not work, but `.classed('c3', true)` on the svg object did work (see my edits to the my question). I did not need to modify any css to get the effect I wanted (so far). – Shafique Jamal Nov 12 '17 at 19:26
  • 1
    @ShafiqueJamal yes `svg.classList.add` was expecting `svg` to be an SVGSVGElement (i.e the DOM node directly). In your case, it's an d3 object. To get the DOM node, you would call its `.node()` method, but as you found out, you can also directly use the d3 `.classed` method. And for the white background, by default it seems to be transparent even on c3. You can always add a style on your svg node (`svg.style('background-color', 'white');`). – Kaiido Nov 13 '17 at 00:34