Skip to content

Commit 440aa61

Browse files
Notebooks: Remote SVG Support (#11046)
Addresses #10594 This PR addresses #10594 which adds remote SVG rendering support in Positron Notebooks to bring in parity with built-in notebooks. Remote image rendering already works for other image formats, this change specifically handled SVGs. ## Explanation Built-in notebooks render markdown cells inside a webview and the markdown rendering is done via an extension on the extension host thread. When images (including remote SVGs) are loaded from within this webview, the request comes from a webview frame. In `src/vs/code/electron-main/app.ts`, there is logic that blocks remote SVGs by default, but allows them if the request comes from a "safe context" (i.e. a webview frame). This is how built-in notebooks are able to render remote SVGs. Positron notebooks render markdown in the main thread which means that requests for remote SVGs come from the main window frame, which is not considered a "safe context". When a remote SVG is loaded from Positron notebooks: 1. The request comes from the main workbench frame 2. The `isSafeFrame()` function (`electron-main/app.ts:225`) returns false (no webview in the frame hierarchy) 3. The SVG request is blocked by the security check in `electron-main/app.ts:301` ## Solution The solution in this PR is to fetch remote SVGs and then converts them to base64 data URLs via the positron-notebooks extension. The base64 data URL is then sent back to the main thread and rendered in the notebook. NOTE: Because it can take time to fetch remote images, we show a placeholder where the image should be while we fetch the image. This is more obvious with svgs than other remote image formats and is visible in the video below. When reloading the Positron window or on startup this is pretty noticeable as of right now. ## Screenshots **render svg - new md cell** https://github.com/user-attachments/assets/32ab381a-864f-4bec-9d50-1dab1da30902 **render svg - window reload** https://github.com/user-attachments/assets/1766fa82-c43c-4b61-9481-c15a477d37a5 **render svg - view to edit mode transition** https://github.com/user-attachments/assets/4457bbcf-16c2-402b-a0e9-3cda845abfb5 ### Release Notes <!-- Optionally, replace `N/A` with text to be included in the next release notes. The `N/A` bullets are ignored. If you refer to one or more Positron issues, these issues are used to collect information about the feature or bugfix, such as the relevant language pack as determined by Github labels of type `lang: `. The note will automatically be tagged with the language. These notes are typically filled by the Positron team. If you are an external contributor, you may ignore this section. --> #### New Features - N/A #### Bug Fixes - N/A ### QA Notes @:positron-notebooks I used the following markdown for testing this change: ``` ### these are the remote images being used Reference url with .svg ![Alt text](https://www.dataquest.io/wp-content/uploads/2023/02/DQ-Logo.svg) Reference positron image url ![test image](https://positron.posit.co/assets/images/positron-logo_fullcolor-TM.png) ``` <!-- Positron team members: please add relevant e2e test tags, so the tests can be run when you open this pull request. - Instructions: https://github.com/posit-dev/positron/blob/main/test/e2e/README.md#pull-requests-and-test-tags - Available tags: https://github.com/posit-dev/positron/blob/main/test/e2e/infra/test-runner/test-tags.ts --> <!-- Add additional information for QA on how to validate the change, paying special attention to the level of risk, adjacent areas that could be affected by the change, and any important contextual information not present in the linked issues. -->
1 parent 689c28e commit 440aa61

File tree

3 files changed

+233
-46
lines changed

3 files changed

+233
-46
lines changed

extensions/positron-notebooks/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
{
2020
"command": "positronNotebookHelpers.convertImageToBase64",
2121
"title": "Convert Image to Base64"
22+
},
23+
{
24+
"command": "positronNotebookHelpers.fetchRemoteImage",
25+
"title": "Fetch Remote Image"
2226
}
2327
]
2428
},

extensions/positron-notebooks/src/extension.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
import path from 'path';
77
import * as vscode from 'vscode';
88
import { readFile } from 'fs';
9+
import * as https from 'https';
10+
import * as http from 'http';
911

1012
// Make sure this matches the error message type defined where used
1113
// (src/vs/workbench/contrib/positronNotebook/browser/notebookCells/DeferredImage.tsx)
12-
type CoversionErrorMsg = {
14+
type ConversionErrorMsg = {
1315
status: 'error';
1416
message: string;
1517
};
@@ -23,7 +25,7 @@ export function activate(context: vscode.ExtensionContext) {
2325
context.subscriptions.push(
2426
vscode.commands.registerCommand(
2527
'positronNotebookHelpers.convertImageToBase64',
26-
async (imageSrc: string, baseLoc: string) => new Promise<string | CoversionErrorMsg>((resolve) => {
28+
async (imageSrc: string, baseLoc: string) => new Promise<string | ConversionErrorMsg>((resolve) => {
2729
const fullImagePath = path.join(baseLoc, imageSrc);
2830
const fileExtension = path.extname(imageSrc).slice(1);
2931
const mimeType = mimeTypeMap[fileExtension.toLowerCase()];
@@ -52,13 +54,95 @@ export function activate(context: vscode.ExtensionContext) {
5254
});
5355
} catch (e) {
5456
return {
55-
type: 'error',
57+
status: 'error',
5658
message: e instanceof Error ? e.message : `Error occured while converting image ${fullImagePath} to base64.`,
5759
};
5860
}
5961
})
6062
)
6163
);
64+
65+
/**
66+
* Command that fetches a remote image and converts it to a base64 data URL.
67+
*
68+
* This command is used to load remote SVG images in Positron notebooks.
69+
* Positron notebooks render markdown directly in the main window context
70+
* and not in a webview like built-in VS Code notebooks. VS Code blocks
71+
* remote SVGs unless it is for the notebook webview (see `src/vs/code/electron-main/app.ts`)
72+
*/
73+
context.subscriptions.push(
74+
vscode.commands.registerCommand(
75+
'positronNotebookHelpers.fetchRemoteImage',
76+
(imageUrl: string) => new Promise<string | ConversionErrorMsg>((resolve) => {
77+
// Determine protocol
78+
const protocol = imageUrl.startsWith('https:') ? https : http;
79+
80+
try {
81+
protocol.get(imageUrl, (response) => {
82+
// Check for successful response
83+
if (response.statusCode !== 200) {
84+
resolve({
85+
status: 'error',
86+
message: `Failed to fetch image: HTTP ${response.statusCode}`,
87+
});
88+
return;
89+
}
90+
91+
// Get MIME type from response headers or infer from URL
92+
let mimeType = response.headers['content-type'];
93+
if (!mimeType || !mimeType.startsWith('image/')) {
94+
// Try to infer from URL extension
95+
const urlExtension = path.extname(new URL(imageUrl).pathname).slice(1);
96+
mimeType = mimeTypeMap[urlExtension.toLowerCase()];
97+
if (!mimeType) {
98+
resolve({
99+
status: 'error',
100+
message: `Unsupported or missing content type: ${response.headers['content-type']}`,
101+
});
102+
return;
103+
}
104+
}
105+
106+
// Collect response data
107+
const chunks: Buffer[] = [];
108+
response.on('data', (chunk) => {
109+
chunks.push(Buffer.from(chunk));
110+
});
111+
112+
response.on('end', () => {
113+
try {
114+
const buffer = Buffer.concat(chunks);
115+
const base64 = buffer.toString('base64');
116+
resolve(`data:${mimeType};base64,${base64}`);
117+
} catch (e) {
118+
resolve({
119+
status: 'error',
120+
message: e instanceof Error ? e.message : 'Failed to convert image to base64',
121+
});
122+
}
123+
});
124+
125+
response.on('error', (err) => {
126+
resolve({
127+
status: 'error',
128+
message: err.message,
129+
});
130+
});
131+
}).on('error', (err) => {
132+
resolve({
133+
status: 'error',
134+
message: err.message,
135+
});
136+
});
137+
} catch (e) {
138+
resolve({
139+
status: 'error',
140+
message: e instanceof Error ? e.message : `Error occurred while fetching image ${imageUrl}`,
141+
});
142+
}
143+
})
144+
)
145+
);
62146
}
63147

64148

src/vs/workbench/contrib/positronNotebook/browser/notebookCells/DeferredImage.tsx

Lines changed: 142 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@ import { usePositronReactServicesContext } from '../../../../../base/browser/pos
2222
* This should match the error message defined in the command definition
2323
* (extensions/positron-notebooks/src/extension.ts)
2424
*/
25-
type CoversionErrorMsg = {
25+
type ConversionErrorMsg = {
2626
status: 'error';
2727
message: string;
2828
};
2929

3030
/**
3131
* Predicate function to allow us to be safe with our response processing from command.
32-
* @param x: Variable of unknown type to check if it is a `CoversionErrorMsg`.
33-
* @returns Whether the object is a `CoversionErrorMsg`.
32+
* @param x: Variable of unknown type to check if it is a `ConversionErrorMsg`.
33+
* @returns Whether the object is a `ConversionErrorMsg`.
3434
*/
35-
function isConversionErrorMsg(x: unknown): x is CoversionErrorMsg {
35+
function isConversionErrorMsg(x: unknown): x is ConversionErrorMsg {
3636
return x !== null && typeof x === 'object' && 'status' in x && x.status === 'error' && 'message' in x;
3737
}
3838

@@ -46,64 +46,144 @@ type ImageDataResults = {
4646
message: string;
4747
};
4848

49+
const REMOTE_SVG_TIMEOUT_MS = 5000;
50+
const CONVERSION_TIMEOUT_MS = 3000;
51+
const ERROR_TIMEOUT_MS = 1000;
52+
4953
/**
5054
* Special image component that defers loading of the image while it converts it to a data-url using
51-
* the `positronNotebookHelpers.convertImageToBase64` command.
55+
* using a command from the `positronNotebookHelpers` extension.
5256
* @param props: Props for `img` element.
5357
* @returns Image tag that shows the image once it is loaded.
5458
*/
55-
// eslint-disable-next-line react/prop-types
5659
export function DeferredImage({ src = 'no-source', ...props }: React.ComponentPropsWithoutRef<'img'>) {
5760
const services = usePositronReactServicesContext();
5861
const notebookInstance = useNotebookInstance();
59-
const baseLocation = getNotebookBaseUri(notebookInstance.uri).path;
6062

6163
const [results, setResults] = React.useState<ImageDataResults>({ status: 'pending' });
6264

6365
React.useEffect(() => {
66+
/**
67+
* Shared helper to handle image conversion.
68+
*
69+
* @param commandName The command to execute
70+
* @param commandArgs Arguments to pass to the command
71+
* @param timeoutMs Timeout in milliseconds for the operation
72+
* @returns A cleanup function to cancel ongoing operations
73+
*/
74+
const handleImageConversion = (
75+
commandName: string,
76+
commandArgs: unknown[],
77+
timeoutMs: number
78+
): (() => void) => {
79+
let delayedErrorMsg: Timeout;
80+
81+
// Create cancelable promise to execute the command with timeout
82+
const conversionCancellablePromise = createCancelablePromise(() => raceTimeout(
83+
services.commandService.executeCommand(commandName, ...commandArgs),
84+
timeoutMs
85+
));
86+
87+
// Handle the conversion result
88+
conversionCancellablePromise.then((payload) => {
89+
if (typeof payload === 'string') {
90+
// Success: got base64 data URL
91+
setResults({ status: 'success', data: payload });
92+
} else if (isConversionErrorMsg(payload)) {
93+
// Known error from the command
94+
delayedErrorMsg = setTimeout(() => {
95+
services.logService.error(
96+
localize('notebook.remoteImage.failedToConvert', "{0} - Failed to convert:", commandName),
97+
commandArgs[0], // image source
98+
payload.message
99+
);
100+
}, ERROR_TIMEOUT_MS);
101+
setResults(payload);
102+
} else {
103+
// Unexpected response format
104+
const unexpectedResponseString = localize('unexpectedResponse', "Unexpected response from {0}", commandName);
105+
delayedErrorMsg = setTimeout(() => {
106+
services.logService.error(unexpectedResponseString, payload);
107+
}, ERROR_TIMEOUT_MS);
108+
setResults({ status: 'error', message: unexpectedResponseString });
109+
}
110+
}).catch((err) => {
111+
// Promise was rejected (timeout or other error)
112+
setResults({ status: 'error', message: err.message });
113+
});
114+
115+
// Return cleanup function for React effect
116+
return () => {
117+
clearTimeout(delayedErrorMsg);
118+
conversionCancellablePromise.cancel();
119+
};
120+
};
64121

65-
// Check for prefix of http or https to avoid converting remote images
66-
if (src.startsWith('http://') || src.startsWith('https://')) {
67-
setResults({ status: 'success', data: src });
68-
return;
69-
}
70-
71-
const conversionTimeoutMs = 3000;
72-
const errorTimeoutMs = 1000;
73-
74-
let delayedErrorMsg: Timeout;
75-
76-
const conversionCancellablePromise = createCancelablePromise(() => raceTimeout(
77-
services.commandService.executeCommand('positronNotebookHelpers.convertImageToBase64', src, baseLocation),
78-
conversionTimeoutMs
79-
));
122+
/**
123+
* Handles fetching and converting remote SVG images to base64 data URLs.
124+
*
125+
* @param imageUrl The SVG URL to fetch
126+
* @returns Cleanup function to cancel ongoing operations
127+
*/
128+
const handleRemoteSvg = (imageUrl: string): (() => void) => {
129+
return handleImageConversion(
130+
'positronNotebookHelpers.fetchRemoteImage',
131+
[imageUrl],
132+
REMOTE_SVG_TIMEOUT_MS
133+
);
134+
};
80135

81-
conversionCancellablePromise.then((payload) => {
82-
if (typeof payload === 'string') {
83-
setResults({ status: 'success', data: payload });
84-
} else if (isConversionErrorMsg(payload)) {
136+
/**
137+
* Handles converting local/relative image paths to base64 data URLs.
138+
*
139+
* @param imagePath The relative image path
140+
* @param baseLocation The base directory to resolve relative paths from
141+
* @returns Cleanup function to cancel ongoing operations
142+
*/
143+
const handleLocalImage = (imagePath: string, baseLocation: string): (() => void) => {
144+
return handleImageConversion(
145+
'positronNotebookHelpers.convertImageToBase64',
146+
[imagePath, baseLocation],
147+
CONVERSION_TIMEOUT_MS
148+
);
149+
};
85150

86-
delayedErrorMsg = setTimeout(() => {
87-
services.logService.error(localize('failedToConvert', 'Failed to convert image to base64:'), src, payload.message);
88-
}, errorTimeoutMs);
151+
/* ---- Main useEffect logic starts here ---- */
89152

90-
setResults(payload);
153+
// Check for remote images (http/https URLs)
154+
if (src.startsWith('http://') || src.startsWith('https://')) {
155+
/**
156+
* Remote SVGs are blocked by VS Code's security policy when loaded directly
157+
* in the main window context (see `src/vs/code/electron-main/app.ts:221-303`).
158+
* We safely handle SVGs by fetching the svg via the extension host and
159+
* converting to base64 data URLs.
160+
*
161+
* Other formats (PNG, JPG, etc.) work natively and can be loaded directly.
162+
*/
163+
const isSvg = isSvgUrl(src);
164+
if (isSvg) {
165+
// Handle remote SVG through extension
166+
return handleRemoteSvg(src);
91167
} else {
92-
const unexpectedResponseString = localize('unexpectedResponse', 'Unexpected response from convertImageToBase64');
93-
delayedErrorMsg = setTimeout(() => {
94-
services.logService.error(unexpectedResponseString, payload);
95-
}, errorTimeoutMs);
96-
setResults({ status: 'error', message: unexpectedResponseString });
168+
// Non-SVG remote images (PNG, JPG, etc.) work natively, use direct URL
169+
setResults({ status: 'success', data: src });
170+
return;
97171
}
98-
}).catch((err) => {
99-
setResults({ status: 'error', message: err.message });
100-
});
172+
}
101173

102-
return () => {
103-
clearTimeout(delayedErrorMsg);
104-
conversionCancellablePromise.cancel();
105-
};
106-
}, [src, baseLocation, services]);
174+
// Otherwise, handle local/relative image paths
175+
let baseLocation: string;
176+
try {
177+
// Get base location to resolve relative paths
178+
baseLocation = getNotebookBaseUri(notebookInstance.uri).path;
179+
} catch (error) {
180+
setResults({ status: 'error', message: String(error) });
181+
return;
182+
}
183+
184+
// Convert local image to base64 data URL
185+
return handleLocalImage(src, baseLocation);
186+
}, [src, notebookInstance, services]);
107187

108188
switch (results.status) {
109189
case 'pending':
@@ -122,6 +202,25 @@ export function DeferredImage({ src = 'no-source', ...props }: React.ComponentPr
122202
}
123203
}
124204

205+
/**
206+
* Detects if a URL points to an SVG file based on its path extension.
207+
*
208+
* @param url The URL to check
209+
* @returns true if the URL path ends with .svg extension
210+
*/
211+
function isSvgUrl(url: string): boolean {
212+
try {
213+
const urlObj = new URL(url);
214+
// Get the pathname without query params or fragments
215+
const pathname = urlObj.pathname.toLowerCase();
216+
// Check if it ends with .svg
217+
return pathname.endsWith('.svg');
218+
} catch {
219+
// If URL parsing fails, fall back to simple check
220+
return url.toLowerCase().endsWith('.svg');
221+
}
222+
}
223+
125224
function getNotebookBaseUri(notebookUri: URI) {
126225
if (notebookUri.scheme === Schemas.untitled) {
127226
// TODO: Use workspace context service to set the base URI to workspace root

0 commit comments

Comments
 (0)