33 * Distributed under the terms of the GNU Affero General Public License v3.0 License.
44 */
55
6- import { CodeCell } from '@jupyterlab/cells' ;
76import { ChartWizardData } from '../ChartWizardPlugin' ;
7+ import { findChartImageDataUrl } from './imageFinder' ;
8+ import { saveWithFilePicker , isFileSystemAccessAvailable } from './fileSaver' ;
9+ import { downloadImage } from './download' ;
10+ import { ExportImageFormat } from './types' ;
811
912export type ExportChartResult = { success : true } | { success : false ; error : string } ;
1013
11- export type ExportImageFormat = 'png' | 'jpeg' ;
12-
13- const SUGGESTED_NAMES : Record < ExportImageFormat , string > = {
14- png : 'chart.png' ,
15- jpeg : 'chart.jpg'
16- } ;
17-
18- type FindImageResult =
19- | { ok : true ; dataUrl : string }
20- | { ok : false ; error : string } ;
21-
22- function findChartImageDataUrl ( chartData : ChartWizardData ) : FindImageResult {
23- const notebookPanel = chartData . notebookTracker . find (
24- ( panel ) => panel . id === chartData . notebookPanelId
25- ) ;
26- if ( ! notebookPanel ) {
27- return { ok : false , error : 'Could not find the notebook.' } ;
28- }
29-
30- const cellWidget = notebookPanel . content . widgets . find (
31- ( cell ) => cell . model . id === chartData . cellId
32- ) ;
33- if ( ! ( cellWidget instanceof CodeCell ) ) {
34- return { ok : false , error : 'Could not find the chart cell.' } ;
35- }
36-
37- const outputNode = cellWidget . outputArea . node ;
38- const img = outputNode . querySelector (
39- '.jp-RenderedImage img[src^="data:image"]'
40- ) as HTMLImageElement | null ;
41-
42- if ( ! img || ! img . src || ! img . src . startsWith ( 'data:image' ) ) {
43- return {
44- ok : false ,
45- error : 'No chart image found. Re-run the chart cell and try again.'
46- } ;
47- }
48- return { ok : true , dataUrl : img . src } ;
49- }
50-
51- const JPEG_QUALITY = 1.0 ; // Max quality
52-
53- function dataUrlToJpegBlob ( dataUrl : string ) : Promise < Blob > {
54- return new Promise ( ( resolve , reject ) => {
55- const img = new Image ( ) ;
56- img . crossOrigin = 'anonymous' ;
57- img . onload = ( ) : void => {
58- const canvas = document . createElement ( 'canvas' ) ;
59- canvas . width = img . naturalWidth ;
60- canvas . height = img . naturalHeight ;
61- const ctx = canvas . getContext ( '2d' ) ;
62- if ( ! ctx ) {
63- reject ( new Error ( 'Could not get canvas context' ) ) ;
64- return ;
65- }
66- ctx . drawImage ( img , 0 , 0 ) ;
67- canvas . toBlob (
68- ( blob ) => ( blob ? resolve ( blob ) : reject ( new Error ( 'toBlob failed' ) ) ) ,
69- 'image/jpeg' ,
70- JPEG_QUALITY
71- ) ;
72- } ;
73- img . onerror = ( ) : void => reject ( new Error ( 'Failed to load image' ) ) ;
74- img . src = dataUrl ;
75- } ) ;
76- }
77-
78- async function fallbackDownload ( dataUrl : string , format : ExportImageFormat ) : Promise < void > {
79- const download = ( url : string , filename : string ) : void => {
80- const a = document . createElement ( 'a' ) ;
81- a . href = url ;
82- a . download = filename ;
83- a . click ( ) ;
84- } ;
85- if ( format === 'jpeg' ) {
86- const blob = await dataUrlToJpegBlob ( dataUrl ) ;
87- const url = URL . createObjectURL ( blob ) ;
88- download ( url , SUGGESTED_NAMES . jpeg ) ;
89- URL . revokeObjectURL ( url ) ;
90- } else {
91- download ( dataUrl , SUGGESTED_NAMES . png ) ;
92- }
93- }
94-
95- const FILE_PICKER_TYPES : Record <
96- ExportImageFormat ,
97- Array < { description : string ; accept : Record < string , string [ ] > } >
98- > = {
99- png : [ { description : 'PNG Image' , accept : { 'image/png' : [ '.png' ] } } ] ,
100- jpeg : [ { description : 'JPEG Image' , accept : { 'image/jpeg' : [ '.jpg' , '.jpeg' ] } } ]
101- } ;
102-
103- async function saveWithFilePicker ( dataUrl : string , format : ExportImageFormat ) : Promise < void > {
104- const handle = await ( window as Window & {
105- showSaveFilePicker ?: ( options : {
106- suggestedName ?: string ;
107- types ?: Array < {
108- description : string ;
109- accept : Record < string , string [ ] > ;
110- } > ;
111- } ) => Promise < FileSystemFileHandle > ;
112- } ) . showSaveFilePicker ?.( {
113- suggestedName : SUGGESTED_NAMES [ format ] ,
114- types : FILE_PICKER_TYPES [ format ]
115- } ) ;
116- if ( ! handle ) return ;
117- const blob =
118- format === 'jpeg'
119- ? await dataUrlToJpegBlob ( dataUrl )
120- : await fetch ( dataUrl ) . then ( ( r ) => r . blob ( ) ) ;
121- const writable = await ( handle as FileSystemFileHandle & {
122- createWritable ( ) : Promise < { write ( data : Blob ) : Promise < void > ; close ( ) : Promise < void > } > ;
123- } ) . createWritable ( ) ;
124- try {
125- await writable . write ( blob ) ;
126- } finally {
127- await writable . close ( ) ;
128- }
129- }
14+ export type { ExportImageFormat } ;
13015
13116/**
13217 * Exports the chart image to the user's disk. Uses File System Access API when available
@@ -143,24 +28,17 @@ export async function exportChartImage(
14328 const found = findChartImageDataUrl ( chartData ) ;
14429 if ( ! found . ok ) return { success : false , error : found . error } ;
14530
146- const dataUrl = found . dataUrl ;
147- const fallback = ( ) : Promise < void > => fallbackDownload ( dataUrl , format ) ;
148-
149- if (
150- 'showSaveFilePicker' in window &&
151- typeof ( window as Window & { showSaveFilePicker ?: unknown } ) . showSaveFilePicker ===
152- 'function'
153- ) {
31+ if ( isFileSystemAccessAvailable ( ) ) {
15432 try {
155- await saveWithFilePicker ( dataUrl , format ) ;
33+ await saveWithFilePicker ( found . dataUrl , format ) ;
15634 } catch ( err ) {
15735 if ( ( err as { name ?: string } ) . name === 'AbortError' ) {
15836 return { success : true } ;
15937 }
160- await fallback ( ) ;
38+ await downloadImage ( found . dataUrl , format ) ;
16139 }
16240 } else {
163- await fallback ( ) ;
41+ await downloadImage ( found . dataUrl , format ) ;
16442 }
16543
16644 return { success : true } ;
0 commit comments