Skip to content

Printing a HTML with a chart #7403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
hidegh opened this issue Apr 13, 2025 · 6 comments
Open

Printing a HTML with a chart #7403

hidegh opened this issue Apr 13, 2025 · 6 comments
Labels
bug something broken P3 backlog

Comments

@hidegh
Copy link

hidegh commented Apr 13, 2025

The HTML renders well.

The container "myDiv" has a width of 50% - PIC 1.

Now, when I try to print the HTML to A4, the chart is now overflowing, is outside the parent container, not re-sized.

I've added a rectange on the container for clarity.

PIC 1:
Image

PIC 2:
Image

@hidegh
Copy link
Author

hidegh commented Apr 13, 2025

NOTES:
The parent container has just its width set to 50%!
Height should be auto.

I'm trying also resizing, the graph, but the resizing seems to be done by height, where the inner container uses 100%, so result is odd:

Image

For those doing exp., I'm using extra scripts:

<script>
  window.addEventListener('beforeprint', () => {
    document.querySelectorAll('.js-plotly-plot').forEach(plot => {
      console.log('resizing', plot);
      Plotly.Plots.resize(plot);
    });
  });
</script>

@hidegh
Copy link
Author

hidegh commented Apr 14, 2025

Intermediate solution, good to play with - final should include auto patching all plotly graphs.

<html>
<head>
  <script src='https://cdn.plot.ly/plotly-3.0.1.min.js'></script>
  <style>
    #myDiv {
      width: 50% !important;
      border: 2px solid red;
      /*
      FLEX,
      OVERFLOW HIDDEN,
      FIXED HEIGHT (via VH),
      ...
      */

      /* must set height or use display flex, otherwise "plot-container plotly"'s 100% height will expand the container to stretch over multiple pages... */
      display: flex;
    }

    #myDiv .main-svg {
      border: 2px dashed red;
    }

    #myW {
      background-color: red;
      width: 0;
      height: 10px;
      margin-bottom: 10px;
    }
  </style>
</head>

<body>

  <div id="myW"></div>

  <div id='myDiv'>
    <!-- Plotly chart will be drawn inside this DIV -->
  </div>

  <img id="img" style="width: 25%" />

  <script>
    var trace1 = {
      x: ['giraffes', 'orangutans', 'monkeys'],
      y: [20, 14, 23],
      name: 'SF Zoo',
      type: 'bar'
    };

    var trace2 = {
      x: ['giraffes', 'orangutans', 'monkeys'],
      y: [12, 18, 29],
      name: 'LA Zoo',
      type: 'bar'
    };

    var data = [trace1, trace2];

    var layout = { barmode: 'stack' };

    Plotly.newPlot('myDiv', data, layout, { responsive: false });
  </script>

  <script>

    const resizeFn = () => {

      var container = document.querySelector('#myDiv');
      var containerBox = container.getBoundingClientRect();

      var svg = document.querySelector("#myDiv .svg-container");
      var svgBox = svg.getBoundingClientRect();
      
      var ratio = svgBox.width / svgBox.height;
      // overriden with a more reliable offsetWidth as getBoundingClientRect().width is affected by zooming, scaling, or print mode!
      ratio = svg.offsetWidth / svg.offsetHeight;

      var w = containerBox.width;
      // overriden with a more reliable offsetWidth as getBoundingClientRect().width is affected by zooming, scaling, or print mode!
      w = container.offsetWidth;
      var h = w / ratio;

      console.log('container size', containerBox.width, containerBox.height);
      console.log('svg size', svgBox.width, svgBox.height);
      console.log('svg w/h ratio', ratio);
      console.log('new size', w, h);

      var visualizer = document.querySelector('#myW');
      visualizer.style.width = w;

      Plotly.relayout(container, { width: w, height: h, autosize: true })

    }

    window.onresize = () => {
      console.log('on resize');
      resizeFn();
    }

    window.matchMedia('print').onchange = (print) => {
      console.log('media change');
      if (print.matches) {
        console.log('- match -> resize');
        resizeFn();
      } else {
        console.log('- no match');
        resizeFn();
      }
    }

    /*
    window.onbeforeprint = () => {
      console.log('on before print');
    }

    window.onafterprint = () => {
      console.log('on after print');
    }
    */

  </script>

</body>

</html>

@gvwilson gvwilson added bug something broken P3 backlog labels Apr 14, 2025
@rodriguesfred
Copy link

Would love to have this fix. Although I'm using plotly.py. I can't print any pages with charts and get them to resize properly

@hidegh
Copy link
Author

hidegh commented Apr 24, 2025

@rodriguesfred i was also experimenting (AI helped here a lot) to just store all the data and setup in a JS variable and then use Plotly to re-draw all the charts.

What I've have found out is this:

  • for some reason, resizing is something that works best when switching media due to print
  • and when resizing the window, it's re-drawing that gave the most appealing look

Let me help you a bit to get you started, here's the HTML, try it out, play with it a bit...
And if you got any better ways to unify the charting (e.g. better method definition, less tied to plotly) - let me know.

Anyhow, with an extra script at the end, you should now be able to code around the issue!

<html>
<head>
  <script src='https://cdn.plot.ly/plotly-3.0.1.min.js'></script>
  <style>
    :root {
      font-family: Helvetica, Arial, sans-serif;
    }

    @media print {
      .plotly {
        page-break-inside: avoid;
        break-inside: avoid;
      }
    }

    .myPlotly {
      width: 50% !important;
      border: 2px solid red;
      /*
      FLEX,
      OVERFLOW HIDDEN,
      FIXED HEIGHT (via VH),
      ...
      */

      /* must set height or use display flex, otherwise "plot-container plotly"'s 100% height will expand the container to stretch over multiple pages... */
      display: flex;
    }

    #myDiv .main-svg {
      border: 2px dashed red;
    }

    @media print {
      #myDiv {
        display: none !important;
      }
    }

    #myW {
      background-color: red;
      width: 0;
      height: 10px;
      margin-bottom: 10px;
    }
  </style>
</head>

<body>

  <p>Check font family</p>

  <div id="myW"></div>

  <div id='myDiv' class="myPlotly">
    <!-- Plotly chart will be drawn inside this DIV -->
  </div>

  <div id='myPie' class="myPlotly">
    <!-- Plotly chart will be drawn inside this DIV -->
  </div>

  <div id='myStacked' class="myPlotly">
    <!-- Plotly chart will be drawn inside this DIV -->
  </div>

  <img id="img" style="width: 25%" />

  <script>

    const remToPx = rem => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);

    const plotlyResizeConst = .8;
    const plotlyLayoutDefaults = {
      font:  {
        family: "Helvetica, Arial, sans-serif",
        size: remToPx(.8) * plotlyResizeConst
      },
      // NOTE:
      // Margins behave weirdly, trimming x/y axis, legend, etc.!
      // So adjusted it with some "best" working values
      margin: {
        l: remToPx(5),
        r: remToPx(5),
        t: remToPx(4),
        b: remToPx(1.5),
        autoexpand: true
      },
      title: {
        font: {
          size: remToPx(1.4) * plotlyResizeConst
        }
      },
      legend: {
        font: {
          size: remToPx(.8) * plotlyResizeConst
        }
      },
      textfont: {
        size: remToPx(1) * plotlyResizeConst
      },
      marker: {
        
      },
      annotations: [{
        /* inside layout annotation is an array, we need to apply settings for each */
        font: {
          size: remToPx(1.4) * plotlyResizeConst
        }
      }]
    }

    const defaultUiColors = ['#BCC5CD', '#96A6B4', '#5C7D95', '#135B7C', '#10516E', '#0C445E'];

    function debounce(fn, delay) {
      let timeout;
      return function(...args) {
        clearTimeout(timeout);
        timeout = setTimeout(() => fn.apply(this, args), delay);
      };
    }    

    function deepMerge(target, source, skipPaths = [], currentPath = "") {
      for (let key in source) {
        if (source.hasOwnProperty(key)) {
          const path = currentPath ? `${currentPath}.${key}` : key;

          // Skip merging if the path is in skipPaths
          if (skipPaths.some(skipPath => path === skipPath || path.startsWith(skipPath + "."))) {
            continue;
          }

          if (typeof source[key] === 'object' && source[key] !== null && typeof target[key] === 'object' && target[key] !== null) {
            deepMerge(target[key], source[key], skipPaths, path);
          } else {
            target[key] = source[key];
          }
        }
      }
      return target;
    }

    function applyDefaultsToAnnotations(target, defaults) {
      if (target.annotations && Array.isArray(target.annotations)) {
        target.annotations.forEach(annotation => {
          if (defaults.annotations[0])
            deepMerge(annotation, defaults.annotations[0]);
        });
      }
    }

    function getLayoutWithDefaults(target) {
      deepMerge(target, plotlyLayoutDefaults, [ "annotations" ]);
      applyDefaultsToAnnotations(target, plotlyLayoutDefaults);
      return target;
    }

    const pieData = {
      CategoryNames: [
        "Cat A",
        "Cat B",
        "Cat C",
        "Cat D"
      ],
      Series: {
        "Pie": [
          482571455.57999998,
          524544.42000000004,
          369163000,
          2556778000
        ]
      },
      ValueFormatCode: "$#,##0"
    };

    function toPlotlyPieData(data, { title, colors /* array */, hole /* num: .4 */, annotation /* html, use <br> */} = {}) {

      const defaultPieChartColors = defaultUiColors;

      const seriesName = Object.keys(data.Series)[0];
      const seriesValues = data.Series[seriesName];

      return {
        data: [{
          // our data
          labels: data.CategoryNames,
          values: seriesValues,
          // plotly tweaks
          type: 'pie',
          sort: false,
          direction: 'clockwise',
          automargin: true,
          hole: hole != undefined ? hole : 0,
          marker: {
            colors: colors ? colors : defaultPieChartColors
          }
        }],
        layout: getLayoutWithDefaults({
            // additional plotly layout
            title: {
              text: title ? title : seriesName
            },
            annotations: [{
              text: annotation ? annotation : '',
              showarrow: false,
            }]
        })
      }
    }

    const stackedData = {
      CategoryNames: ["A", "B", "C", "D", "E", "Other"],
      Series: {
        "Used": [
          0.040082114696816558,
          0.026988317172442423,
          0.02650146962197153,
          0.012419983509355888 * 1, // * 1000 * 1000 * 1000 * 1000,
          0.007896725890937935,
          0.030119969131519604
        ],
        "Remaining": [
          0.10991788530318346,
          0.12301168282755758,
          0.12349853037802846,
          0.13758001649064414,
          0.14210327410906207,
          0.11988003086848041
        ]
      }      
    };

    function toPlotlyStackedBarData(data, { title, colors, texttemplate /* '%{y:.2%}', '$%{y:,.0f}' */, textposition /* auto, none */, hovertemplate /* '%{x}-%{data.name}: $%{y:,.0f}' */, yaxisformat /* '.0%', '$,.2f' */ } = {}) {

      const currentUiColors = ['#2c7abb', '#8cb554'];
      const defaultStackedBarChartColors = [ defaultUiColors[3], defaultUiColors[0] ];

      const categoryNames = data.CategoryNames;
      const series = data.Series;
      
      const traces = [];           
      Object.entries(series).forEach(([seriesName, seriesData], i) => {
        traces.push({
          x: categoryNames,
          y: seriesData,
          name: seriesName,
          type: 'bar',
          texttemplate: texttemplate || '%{y:.2%}',
          textposition: textposition || 'auto',
          hovertemplate: hovertemplate || '' /* default */,
          marker: {
            color: (colors || defaultStackedBarChartColors)[i]
          }
        });
      });

      return {
        data: traces,
        layout: getLayoutWithDefaults({
            // additional plotly layout
            barmode: 'stack',
            title: {
              text: title
            },
            yaxis: {
                tickformat: yaxisformat || '.0%'
            },
        })
      };

    }

    function toPlotlyStackedBarData_Percentual(data, { title, textposition, decimals = 2, axisdecimals = 0 }) {
      return toPlotlyStackedBarData(data, { title, textposition, texttemplate: `%{y:,.${decimals}%}`, hovertemplate: `%{x}-%{data.name}: %{y:,.${decimals}%}`, yaxisformat: `,.${axisdecimals}%` })
    }

    function toPlotlyStackedBarData_Currency(data, { title, textposition, decimals = 0, axisdecimals = 0 }) {
      return toPlotlyStackedBarData(data, { title, textposition, texttemplate: `$%{y:,.${decimals}f}`, hovertemplate: `%{x}-%{data.name}: $%{y:,.${decimals}f}`, yaxisformat: `$,.${axisdecimals}f` })
    }

</script>

  <script>

    /*
     * Resize - sometimes odd output...
     */
    const resizeFn = (container) => {

      var containerBox = container.getBoundingClientRect();
     
      var svg = container.querySelector(".svg-container");
      var svgBox = svg.getBoundingClientRect();
      
      var ratio = svgBox.width / svgBox.height;
      // overriden with a more reliable offsetWidth as getBoundingClientRect().width is affected by zooming, scaling, or print mode!
      ratio = svg.offsetWidth / svg.offsetHeight;

      // skip elements that won't be displayed (media print, diplay none)...
      if (Number.isNaN(ratio))
        return;

      var w = containerBox.width;
      // overriden with a more reliable offsetWidth as getBoundingClientRect().width is affected by zooming, scaling, or print mode!
      w = container.offsetWidth;
      var h = w / ratio;

      console.log('container size', containerBox.width, containerBox.height);
      console.log('svg size', svgBox.width, svgBox.height);
      console.log('svg w/h ratio', ratio);
      console.log('new size', w, h);

      Plotly.relayout(container, { width: w, height: h, autosize: false })
    }

    const resizeAllChartsFn = () => {
      document.querySelectorAll('.js-plotly-plot').forEach(resizeFn);
    };
    
    /*
     * REDRAW - store data
     */

    const chartStore = new Map();

    const setChartData = (idSelector, data, layout) => {
      chartStore.set(idSelector, { data, layout });
    };

    const drawChart = (idSelector) => {
      const entry = chartStore.get(idSelector);
      if (!entry) return;

      const container = document.querySelector(idSelector);
      if (!container) return;

      Plotly.newPlot(container, entry.data, entry.layout);
    };

    const redrawCharts = () => {
      // Iterate over each entry in chartStore
      chartStore.forEach((chartData, id) => {
        const container = document.querySelector("#" + id);
        if (!container) return;

        const { data: chart, layout } = chartData;

        /*
        // SVG is only on re-draws!
        const svg = container.querySelector('.svg-container');
        if (!svg) return;

        const ratio = svg.offsetWidth / svg.offsetHeight;
        const width = container.offsetWidth;
        const height = width / ratio;

        const newLayout = { ...layout, width, height, autosize: true };
        */

        const newLayout = { ...chart.layout, ...layout, autosize: true };

        Plotly.newPlot(container, chart.data, newLayout);
      });
    };

    /*
     * Setup (if needed)
     */    

    Plotly.newPlot(document.querySelector("#myDiv"), toPlotlyStackedBarData(stackedData).data, { barmode: 'stack' });

    var data = [{
      values: [19, 26, 55],
      labels: ['Residential', 'Non-Residential', 'Utility'],
      type: 'pie'
    }];

    Plotly.newPlot('myDiv', data, { height: 400, width: 500 });

    setChartData("myPie", toPlotlyPieData(pieData, { hole: .4 }));
    setChartData("myStacked", toPlotlyStackedBarData_Percentual(stackedData, { title: 'Stacked' }));
    redrawCharts();

    /*
     * Events
     */
    
    window.matchMedia('print').onchange = (print) => {
      // Do NOT use debounce here!
      // NOTE: seems redraw works nicer - keeps the ratio's that we have on direct HTML output...
      console.log('media change');
      if (print.matches) {
        console.log('- match -> resize');
        resizeAllChartsFn();
      } else {
        console.log('- no match');
        resizeAllChartsFn();
      }
    }

    const resizeAllDebounced = debounce(resizeAllChartsFn, 1000);
    const refreshAllDebounced = debounce(redrawCharts, 100);
    window.onresize = () => resizeAllDebounced();
    
    /*
    window.onbeforeprint = () => {
      console.log('on before print');
    }

    window.onafterprint = () => {
      console.log('on after print');
    }
    */

  </script>

</body>

</html>

@FraJoMen
Copy link

Hi, and thanks for your work on Plotly.js.

We're experiencing issues when printing HTML reports with embedded Plotly graphs using the browser's print function (e.g. Save as PDF). Everything displays correctly on screen, but:

  • Graphs sometimes disappear or render as blank.
  • Other times, they get clipped or distorted.
  • This happens especially when printing in A4 vertical format.

Interestingly, printing the same HTML in A3 landscape works fine. We’ve tried CSS @media print rules (scale, max-height, transform), but the issue seems to depend on how the browser rasterizes the Plotly canvas/SVG during print.

Use case: We generate scientific/engineering reports from Jupyter notebooks as standalone HTML and want users to print them directly (without exporting each figure to PNG). In our case, exporting via LaTeX is not a practical or convenient option.

We’ve attached a ZIP file containing a simplified HTML example (esempio_progetto.html) that reproduces the issue.

Could this be improved or officially supported? Any recommended patterns to ensure consistent PDF output?

Thanks!

esempio_progetto.zip

@rodriguesfred
Copy link

rodriguesfred commented May 14, 2025

@hidegh thank you so much for you code! It really helped. Made some modifications and works a treat! I don't actually know javascript so the code could do with some tiding up.

I'm working in plotly.py so I put some modification of your code (see below) in a print.js in assets folder. I then had to load the script in the app by having the following element somewhere in my app.
html.Script(src="/assets/print.js")

print.js

// Function to resize a single chart container
const resizeFn = (jsPlotlyChart) => {
    // This is the structure of the chart div.dash-graph --> div.js-plotly-plot --> div.plot-container --> div.svg-container --> svg
    // All margins and padding are 0 so width and offsetwidth of the divs should in theory be the same. Confirmed via consoloe.log.

    // Get the dash Graph element
    var dashGraph = jsPlotlyChart.parentElement;

    // Calculate the aspect ratio of the SVG element
    var ratio = dashGraph.offsetWidth / dashGraph.offsetHeight;
    console.log('svg w/h ratio', ratio);

    // Skip elements that won't be displayed (e.g., those with display: none)
    if (Number.isNaN(ratio))
        return;

    // Get the width of the dash Graph container
    var w = dashGraph.offsetWidth;

    // Calculate the height based on the aspect ratio
    var h = w / ratio;
    console.log('new size', w, h);

    // Update the Plotly chart with the new width and height
    try {
        Plotly.relayout(jsPlotlyChart, { width: w, height: h, autosize: false });
    } catch (error) {
        console.error("Error resizing chart:", error);
    }
}

// Function to resize all Plotly charts on the page
const resizeAllChartsFn = () => {
    // Select all Plotly chart elements and apply the resize function to each
    document.querySelectorAll('.js-plotly-plot').forEach(resizeFn);
};

// Create a Map to store chart data and layout configurations
const chartStore = new Map();

// Function to set chart data and layout in the chartStore
const setChartData = (idSelector, data, layout) => {
    chartStore.set(idSelector, { data, layout });
};

// Function to redraw all charts stored in chartStore
const redrawCharts = () => {
    // Iterate over each entry in chartStore
    chartStore.forEach((chartData, id) => {
        const container = document.querySelector("#" + id);
        if (!container) return;

        const { data: chart, layout } = chartData;

        // Update the layout with autosize set to true
        const newLayout = { ...chart.layout, ...layout, autosize: true };

        Plotly.newPlot(container, chart.data, newLayout);
    });
};

// Debounce function to limit the rate at which a function can fire
function debounce(fn, delay) {
    let timeout;  // Declare a variable to store the timeout ID

    return function (...args) {  // Return a new function that takes any number of arguments
        clearTimeout(timeout);  // Clear the previous timeout, if any
        timeout = setTimeout(() => fn.apply(this, args), delay);  // Set a new timeout to call the function after the specified delay
    };
}

/*
    * Events
    */

// Event listener for media query changes (e.g., print mode)
window.matchMedia('print').onchange = (print) => {
    // Do NOT use debounce here!
    // NOTE: seems redraw works nicer - keeps the ratio's that we have on direct HTML output...
    console.log('media change');
    if (print.matches) {
        console.log('- match -> resize');
        resizeAllChartsFn();
    } else {
        console.log('- no match');
        resizeAllChartsFn();
    }
}

// Debounced functions for resizing and redrawing charts
const resizeAllDebounced = debounce(resizeAllChartsFn, 1000);
const refreshAllDebounced = debounce(redrawCharts, 100);
window.onresize = () => resizeAllDebounced();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug something broken P3 backlog
Projects
None yet
Development

No branches or pull requests

4 participants