From a6c2a234ffa4753f9df41eae597622a2e1b90626 Mon Sep 17 00:00:00 2001 From: JeS24 Date: Sat, 10 Oct 2026 02:10:51 +0200 Subject: [PATCH 1/4] fix: Show heatmap and image trace PNGs through blob URLs --- src/lib/image_href.ts | 90 +++++++++++++++++++++++++++++++++++++ src/plot_api/subroutines.js | 5 +++ src/plots/plots.js | 5 +++ src/snapshot/tosvg.js | 6 +++ src/traces/heatmap/plot.js | 6 ++- src/traces/image/plot.js | 12 +++-- 6 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 src/lib/image_href.ts diff --git a/src/lib/image_href.ts b/src/lib/image_href.ts new file mode 100644 index 00000000000..0a1ebe94a44 --- /dev/null +++ b/src/lib/image_href.ts @@ -0,0 +1,90 @@ +'use strict'; + +import { xlink } from '../constants/xmlns_namespaces'; +import helpers from '../snapshot/helpers'; +import type { GraphDiv } from '../types/core/graph-div.internal'; + +interface ImageNode extends SVGImageElement { + _blobUrl?: string; + _dataUrl?: string; +} + +function releaseBlobUrl(node: ImageNode) { + if (!node._blobUrl) return; + helpers.revokeObjectURL(node._blobUrl); + delete node._blobUrl; + delete node._dataUrl; +} + +/** + * Set `xlink:href` on each selected `` and revoke any blob URL previously assigned by this helper. + * + * Canvas sources use PNG blob URLs, with a stored data URL for SVG export. + * Export plots and empty canvases keep the canvas data URL. + * String sources use the supplied URL unchanged. + * The graph div tracks image nodes that need blob URL cleanup. + * Call `releaseImageHrefs` when images leave the plot or the plot is purged. + * + * @param gd - the graph div that owns the images + * @param image - a d3 selection of `` elements + * @param source - a canvas to encode as a PNG, or a URL to use as is + */ +export function setImageHref(gd: GraphDiv, image: any, source: HTMLCanvasElement | string) { + const href = typeof source === 'string' ? source : source.toDataURL('image/png'); + const useBlob = typeof source !== 'string' && !gd._context?._exportedPlot && helpers.IMAGE_URL_PREFIX.test(href); + const blob = useBlob ? helpers.createBlob(href.replace(helpers.IMAGE_URL_PREFIX, ''), 'png') : null; + const nodes: ImageNode[] = gd._imageBlobNodes || (gd._imageBlobNodes = []); + + image.each(function (this: ImageNode) { + const previous = this._blobUrl; + if (blob) { + this._blobUrl = helpers.createObjectURL(blob); + this._dataUrl = href; + if (nodes.indexOf(this) === -1) nodes.push(this); + } else { + delete this._blobUrl; + delete this._dataUrl; + } + + this.setAttributeNS(xlink, 'href', this._blobUrl || href); + if (previous) helpers.revokeObjectURL(previous); + }); +} + +/** + * Revoke tracked image blob URLs for the graph div. + * Clear the stored URLs and remove the released nodes from the graph div's tracking list. + * + * @param gd - the graph div that owns the images + * @param removedOnly - true to revoke only URLs for images no longer inside `gd`. Defaults to false (revoke all). + */ +export function releaseImageHrefs(gd: GraphDiv, removedOnly?: boolean) { + const nodes: ImageNode[] | undefined = gd._imageBlobNodes; + if (!nodes) return; + + const kept = nodes.filter((node) => { + if (removedOnly && node._blobUrl && gd.contains(node)) return true; + releaseBlobUrl(node); + return false; + }); + + if (kept.length) gd._imageBlobNodes = kept; + else delete gd._imageBlobNodes; +} + +/** + * Replace image blob URLs in serialized SVG with their stored PNG data URLs. + * The source SVG must retain the image nodes and their URL metadata from `setImageHref`. + * + * @param svgNode - the source SVG element + * @param svgString - the serialized SVG to update + * @returns the updated SVG string. Leaves the SVG DOM unchanged. + */ +export function restoreImageDataUrls(svgNode: SVGElement, svgString: string): string { + const images = svgNode.querySelectorAll('image') as NodeListOf; + for (let i = 0; i < images.length; i++) { + const { _blobUrl, _dataUrl } = images[i]; + if (_blobUrl && _dataUrl) svgString = svgString.replace(_blobUrl, () => _dataUrl); + } + return svgString; +} diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js index 666f3e7b6a5..14f298c40df 100644 --- a/src/plot_api/subroutines.js +++ b/src/plot_api/subroutines.js @@ -20,6 +20,8 @@ var enforceAxisConstraints = axisConstraints.enforce; var cleanAxisConstraints = axisConstraints.clean; var doAutoRange = require('../plots/cartesian/autorange').doAutoRange; +const { releaseImageHrefs } = require('../lib/image_href'); + var SVG_TEXT_ANCHOR_START = 'start'; var SVG_TEXT_ANCHOR_MIDDLE = 'middle'; var SVG_TEXT_ANCHOR_END = 'end'; @@ -734,6 +736,9 @@ exports.drawData = function(gd) { basePlotModules[i].plot(gd); } + // Release blob URLs for image nodes removed during this redraw. + releaseImageHrefs(gd, true); + exports.redrawReglTraces(gd); // styling separate from drawing diff --git a/src/plots/plots.js b/src/plots/plots.js index ad53d4dd3e9..3e9f0df621b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -23,6 +23,8 @@ var frameAttrs = require('./frame_attributes'); var getModuleCalcData = require('../plots/get_data').getModuleCalcData; +const { releaseImageHrefs } = require('../lib/image_href'); + var relinkPrivateKeys = Lib.relinkPrivateKeys; var _ = Lib._; @@ -1473,6 +1475,9 @@ plots.purge = function(gd) { // Cancel the pending redraw for web fonts delete gd._fontLoadToken; + // Release all image blob URLs before the graph div loses its plot state. + releaseImageHrefs(gd); + // data and layout delete gd.data; delete gd.layout; diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js index 4a172f907ea..62d30af21d3 100644 --- a/src/snapshot/tosvg.js +++ b/src/snapshot/tosvg.js @@ -7,6 +7,9 @@ var Drawing = require('../components/drawing'); var Color = require('../components/color'); var xmlnsNamespaces = require('../constants/xmlns_namespaces'); + +const { restoreImageDataUrls } = require('../lib/image_href'); + var DOUBLEQUOTE_REGEX = /"/g; var DUMMY_SUB = 'TOBESTRIPPED'; // Match TOBESTRIPPED adjacent to either a literal " or its entity form ". @@ -179,5 +182,8 @@ module.exports = function toSVG(gd, format, scale) { // Fix quotations around font strings and gradient URLs s = s.replace(DUMMY_REGEX, "'"); + // Embed the image data so the exported SVG does not depend on temporary blob URLs. + s = restoreImageDataUrls(svg.node(), s); + return s; }; diff --git a/src/traces/heatmap/plot.js b/src/traces/heatmap/plot.js index 05c30d3005d..f33d75b4310 100644 --- a/src/traces/heatmap/plot.js +++ b/src/traces/heatmap/plot.js @@ -17,6 +17,8 @@ var LINE_SPACING = alignmentConstants.LINE_SPACING; var supportsPixelatedImage = require('../../lib/supports_pixelated_image'); var PIXELATED_IMAGE_STYLE = require('../../constants/pixelated_image').STYLE; +const { setImageHref } = require('../../lib/image_href'); + var labelClass = 'heatmap-label'; function selectLabels(plotGroup) { @@ -365,9 +367,9 @@ module.exports = function (gd, plotinfo, cdheatmaps, heatmapLayer) { height: imageHeight, width: imageWidth, x: left, - y: top, - 'xlink:href': canvas.toDataURL('image/png') + y: top }); + setImageHref(gd, image3, canvas); if (drawingMethod === 'fast' && !zsmooth) { image3.attr('style', PIXELATED_IMAGE_STYLE); diff --git a/src/traces/image/plot.js b/src/traces/image/plot.js index 4ed31e063d0..079ab343f16 100644 --- a/src/traces/image/plot.js +++ b/src/traces/image/plot.js @@ -8,6 +8,8 @@ var constants = require('./constants'); var supportsPixelatedImage = require('../../lib/supports_pixelated_image'); var PIXELATED_IMAGE_STYLE = require('../../constants/pixelated_image').STYLE; +const { setImageHref } = require('../../lib/image_href'); + module.exports = function plot(gd, plotinfo, cdimage, imageLayer) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; @@ -184,18 +186,15 @@ module.exports = function plot(gd, plotinfo, cdimage, imageLayer) { } }) .then(function() { - var href, canvas; + let canvas; if(trace._hasZ) { canvas = drawMagnifiedPixelsOnCanvas(function(i, j) { var _z = z[j][i]; if(Lib.isTypedArray(_z)) _z = Array.from(_z); return _z; }); - href = canvas.toDataURL('image/png'); } else if(trace._hasSource) { - if(realImage) { - href = trace.source; - } else { + if(!realImage) { var context = trace._canvas.el.getContext('2d', {willReadFrequently: true}); var data = context.getImageData(0, 0, w, h).data; canvas = drawMagnifiedPixelsOnCanvas(function(i, j) { @@ -207,12 +206,11 @@ module.exports = function plot(gd, plotinfo, cdimage, imageLayer) { data[index + 3] ]; }); - href = canvas.toDataURL('image/png'); } } + setImageHref(gd, image3, realImage ? trace.source : canvas); image3.attr({ - 'xlink:href': href, height: imageHeight, width: imageWidth, x: left, From 046ca6e61efd5418c2e9005e35575f483aa49668 Mon Sep 17 00:00:00 2001 From: JeS24 Date: Sat, 10 Oct 2026 02:10:51 +0200 Subject: [PATCH 2/4] Update internal GraphDiv type --- src/types/core/graph-div.internal.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types/core/graph-div.internal.d.ts b/src/types/core/graph-div.internal.d.ts index a4458741c65..6588dc64294 100644 --- a/src/types/core/graph-div.internal.d.ts +++ b/src/types/core/graph-div.internal.d.ts @@ -42,6 +42,8 @@ export interface GraphDiv extends HTMLDivElement { _fullLayout?: FullLayout; /** Most recent hover payloads (used to deduplicate hover events). */ _hoverdata?: any[]; + /** Image nodes with blob URLs to release after removal or on purge. */ + _imageBlobNodes?: SVGImageElement[]; /** True once `Plotly.plot`/`newPlot` has run at least once. */ _initialized?: boolean; /** Outstanding async operations the next call must await. */ From c9daa7d49a0880d0cb0ec396f2a3f40680e71bfe Mon Sep 17 00:00:00 2001 From: JeS24 Date: Sat, 10 Oct 2026 02:10:52 +0200 Subject: [PATCH 3/4] Add tests --- test/jasmine/tests/heatmap_test.js | 64 +++++++++++++++++++++++++++++- test/jasmine/tests/image_test.js | 3 +- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/test/jasmine/tests/heatmap_test.js b/test/jasmine/tests/heatmap_test.js index ceb527f4132..2d462ec2698 100644 --- a/test/jasmine/tests/heatmap_test.js +++ b/test/jasmine/tests/heatmap_test.js @@ -726,7 +726,7 @@ describe('heatmap plot', function() { var mockCopy = Lib.extendDeep({}, mock); function getImageURL() { - return d3Select('.hm > image').attr('href'); + return d3Select('.hm > image').node()._dataUrl; } var imageURLs = []; @@ -755,6 +755,68 @@ describe('heatmap plot', function() { .then(done, done.fail); }); + it('should show a blob URL and revoke the blob URLs that it no longer shows', function(done) { + const getImage = () => d3Select('.hm > image'); + const revoked = []; + const revokeObjectURL = URL.revokeObjectURL; + spyOn(URL, 'revokeObjectURL').and.callFake((url) => { + revoked.push(url); + revokeObjectURL.call(URL, url); + }); + + const hrefs = []; + const expectRevoked = () => { + hrefs.forEach((href) => expect(revoked).toContain(href)); + }; + + Plotly.newPlot(gd, [{ type: 'heatmap', z: [[1, 2], [3, 4]] }]) + .then(() => { + hrefs.push(getImage().attr('href')); + expect(hrefs[0]).toMatch(/^blob:/); + expect(getImage().node()._dataUrl).toMatch(/^data:image\/png;base64,/); + + return Plotly.restyle(gd, 'colorscale', 'Greens'); + }) + .then(() => { + const href = getImage().attr('href'); + expect(href).toMatch(/^blob:/); + expect(revoked).toContain(hrefs[0]); + expect(revoked).not.toContain(href); + hrefs.push(href); + + return Plotly.react(gd, [{ type: 'scatter', y: [1, 2] }]); + }) + .then(() => { + expectRevoked(); + expect(gd._imageBlobNodes).toBeUndefined(); + + return Plotly.react(gd, [{ type: 'heatmap', z: [[1, 2], [3, 4]] }]); + }) + .then(() => { + hrefs.push(getImage().attr('href')); + Plotly.purge(gd); + expectRevoked(); + }) + .then(done, done.fail); + }); + + it('should put a data URL in an exported SVG', function(done) { + const expectDataUrl = (svg) => { + expect(svg).toContain('data:image/png;base64,'); + expect(svg).not.toContain('blob:'); + }; + + Plotly.newPlot(gd, [{ type: 'heatmap', z: [[1, 2], [3, 4]] }]) + .then(() => Plotly.toImage(gd, { format: 'svg' })) + .then((url) => { + expectDataUrl(decodeURIComponent(url)); + + // Call toSVG last because it modifies the plot DOM. + expectDataUrl(Plotly.Snapshot.toSVG(gd)); + }) + .then(done, done.fail); + }); + it('draws canvas with correct margins', function(done) { var mockWithPadding = require('../../image/mocks/heatmap_brick_padding.json'); var mockWithoutPadding = Lib.extendDeep({}, mockWithPadding); diff --git a/test/jasmine/tests/image_test.js b/test/jasmine/tests/image_test.js index 81fa1d28105..e4aaed1138f 100644 --- a/test/jasmine/tests/image_test.js +++ b/test/jasmine/tests/image_test.js @@ -286,7 +286,8 @@ describe('image plot', function() { }); function getImageURL() { - return d3Select(sel).attr('href'); + const node = d3Select(sel).node(); + return node._dataUrl || d3Select(node).attr('href'); } [ From 1bcca253cf58f947ea6f62b837f415002edde367 Mon Sep 17 00:00:00 2001 From: JeS24 Date: Sat, 10 Oct 2026 02:25:46 +0200 Subject: [PATCH 4/4] Add draftlog --- draftlogs/8114_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/8114_fix.md diff --git a/draftlogs/8114_fix.md b/draftlogs/8114_fix.md new file mode 100644 index 00000000000..431e40ee3bd --- /dev/null +++ b/draftlogs/8114_fix.md @@ -0,0 +1 @@ +- Fix the growth of renderer memory in Chromium when a `heatmap` or `image` trace redraws many times, for example with live data [[#8114](https://github.com/plotly/plotly.js/pull/8114)], with thanks to @JeS24 for the contribution!