diff --git a/.e2e-workspace/assets/name with spaces.svg b/.e2e-workspace/assets/name with spaces.svg
new file mode 100644
index 0000000..f9343f7
--- /dev/null
+++ b/.e2e-workspace/assets/name with spaces.svg
@@ -0,0 +1,3 @@
+
diff --git a/.e2e-workspace/assets/normal-image.svg b/.e2e-workspace/assets/normal-image.svg
new file mode 100644
index 0000000..2b9f925
--- /dev/null
+++ b/.e2e-workspace/assets/normal-image.svg
@@ -0,0 +1,3 @@
+
diff --git a/.e2e-workspace/image-paths.md b/.e2e-workspace/image-paths.md
new file mode 100644
index 0000000..b5b6173
--- /dev/null
+++ b/.e2e-workspace/image-paths.md
@@ -0,0 +1,19 @@
+# Local Image Paths
+
+## Normal filename
+
+
+
+## URL-encoded spaces
+
+
+
+## Angle-bracket destination
+
+
+
+## Reference-style destination
+
+![Reference spaces][image-with-spaces]
+
+[image-with-spaces]: ./assets/name%20with%20spaces.svg
diff --git a/src/extension/preview/markdown/linkResolver.ts b/src/extension/preview/markdown/linkResolver.ts
index f4c39ec..054710a 100644
--- a/src/extension/preview/markdown/linkResolver.ts
+++ b/src/extension/preview/markdown/linkResolver.ts
@@ -101,6 +101,22 @@ function restoreSvgFragment(
return uri.with({ fragment });
}
+function decodeLocalImagePath(rawPath: string): string | undefined {
+ // Encoded separators must not become new path boundaries during URI decoding.
+ if (/%(?:2f|5c)/i.test(rawPath)) {
+ return undefined;
+ }
+
+ try {
+ return rawPath
+ .split('/')
+ .map((segment) => decodeURIComponent(segment))
+ .join('/');
+ } catch {
+ return undefined;
+ }
+}
+
export function resolveImageUri(source: vscode.Uri, src: string): vscode.Uri | undefined {
const { normalizedSrc, fragment } = stripLocalImageUrlDecoration(src);
if (
@@ -113,14 +129,26 @@ export function resolveImageUri(source: vscode.Uri, src: string): vscode.Uri | u
}
const sourceFolder = vscode.workspace.getWorkspaceFolder(source);
if (/^file:/i.test(normalizedSrc)) {
- const parsed = vscode.Uri.parse(normalizedSrc, true);
- const resolved = restoreSvgFragment(parsed, fragment);
- if (!sourceFolder) return resolved;
- return isWithinWorkspace(parsed, sourceFolder.uri) ? resolved : undefined;
+ if (/%(?:2f|5c)/i.test(normalizedSrc)) {
+ return undefined;
+ }
+ try {
+ const parsed = vscode.Uri.parse(normalizedSrc, true);
+ const resolved = restoreSvgFragment(parsed, fragment);
+ if (!sourceFolder) return resolved;
+ return isWithinWorkspace(parsed, sourceFolder.uri) ? resolved : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ const decodedPath = decodeLocalImagePath(normalizedSrc);
+ if (decodedPath === undefined) {
+ return undefined;
}
const resolvedBase = vscode.Uri.joinPath(
source.with({ path: path.posix.dirname(source.path) }),
- normalizedSrc
+ decodedPath
);
const resolved = restoreSvgFragment(resolvedBase, fragment);
if (!sourceFolder) return resolved;
diff --git a/test/e2e/preview.e2e.test.ts b/test/e2e/preview.e2e.test.ts
index 58c8751..6f28c69 100644
--- a/test/e2e/preview.e2e.test.ts
+++ b/test/e2e/preview.e2e.test.ts
@@ -21,9 +21,12 @@ test.describe('preview features (VS Code)', () => {
const linkedSubdoc = await readWorkspaceMarkdown(workspace, 'sub/linked-subdoc.md');
const mermaidEdgeCases = await readWorkspaceMarkdown(workspace, 'mermaid-edge-cases.md');
const remoteImages = await readWorkspaceMarkdown(workspace, 'remote-images.md');
+ const imagePaths = await readWorkspaceMarkdown(workspace, 'image-paths.md');
await access(join(workspace, 'assets/banner.svg'));
await access(join(workspace, 'assets/grid.svg'));
+ await access(join(workspace, 'assets/normal-image.svg'));
+ await access(join(workspace, 'assets/name with spaces.svg'));
expect(sample).toContain('# Sample');
expect(sample).toContain('## Mermaid');
@@ -48,6 +51,10 @@ test.describe('preview features (VS Code)', () => {
expect(remoteImages).toContain('# Remote Images Fixture');
expect(remoteImages).toContain('offlineMarkdownViewer.preview.allowRemoteImages = false');
expect(remoteImages).toContain('Download Image');
+ expect(imagePaths).toContain('](./assets/normal-image.svg)');
+ expect(imagePaths).toContain('](./assets/name%20with%20spaces.svg)');
+ expect(imagePaths).toContain('](<./assets/name with spaces.svg>)');
+ expect(imagePaths).toContain('[image-with-spaces]: ./assets/name%20with%20spaces.svg');
const remoteImageLinks = extractMarkdownImageLinks(remoteImages).filter((link) => isHttpUrl(link));
expect(remoteImageLinks).toEqual(
diff --git a/test/unit/helpers/vscodeMock.ts b/test/unit/helpers/vscodeMock.ts
index 68bdcbf..b527736 100644
--- a/test/unit/helpers/vscodeMock.ts
+++ b/test/unit/helpers/vscodeMock.ts
@@ -22,6 +22,15 @@ function toFsPath(uriPath: string): string {
return /^\/[A-Za-z]:\//.test(uriPath) ? uriPath.slice(1) : uriPath;
}
+function encodeUriPath(uriPath: string): string {
+ return uriPath
+ .split('/')
+ .map((segment) =>
+ encodeURIComponent(segment).replace(/%3A/gi, ':').replace(/%40/gi, '@')
+ )
+ .join('/');
+}
+
export class Uri {
constructor(
public readonly scheme: string,
@@ -35,20 +44,20 @@ export class Uri {
static file(fsPath: string): Uri {
const normalizedFsPath = normalizeSlashes(fsPath);
const uriPath = toUriPath(normalizedFsPath);
- return new Uri('file', normalizedFsPath, uriPath, `file://${uriPath}`);
+ return new Uri('file', normalizedFsPath, uriPath, `file://${encodeUriPath(uriPath)}`);
}
static parse(input: string): Uri {
if (input.startsWith('file://')) {
const match = /^file:\/\/([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/i.exec(input);
- const uriPath = normalizeSlashes(match?.[1] ?? '');
+ const uriPath = decodeURIComponent(normalizeSlashes(match?.[1] ?? ''));
const fsPath = toFsPath(uriPath);
const query = match?.[2] ?? '';
const fragment = match?.[3] ?? '';
return new Uri('file', fsPath, uriPath, input, query, fragment);
}
if (/^https?:\/\//i.test(input)) {
- return new Uri(input.split(':')[0], '', '', input);
+ return new Uri(input.slice(0, input.indexOf(':')), '', '', input);
}
return Uri.file(input);
}
@@ -59,7 +68,7 @@ export class Uri {
'file',
toFsPath(nextUriPath),
nextUriPath,
- `file://${nextUriPath}`
+ `file://${encodeUriPath(nextUriPath)}`
);
}
@@ -67,7 +76,7 @@ export class Uri {
const nextPath = update.path ?? this.path;
const base =
this.scheme === 'file'
- ? `file://${nextPath}`
+ ? `file://${encodeUriPath(nextPath)}`
: `${this.scheme}:${nextPath}`;
const nextQuery = update.query ?? this.query;
const nextFragment = update.fragment ?? this.fragment;
diff --git a/test/unit/linkResolver.test.ts b/test/unit/linkResolver.test.ts
index e9228f7..7167517 100644
--- a/test/unit/linkResolver.test.ts
+++ b/test/unit/linkResolver.test.ts
@@ -27,6 +27,60 @@ describe('linkResolver', () => {
it('blocks preview image resolution outside workspace', () => {
const source = Uri.file('/workspace/docs/a.md');
expect(api.resolveImageUri(source as any, '../../secret.png')).toBeUndefined();
+ expect(api.resolveImageUri(source as any, '%2e%2e/%2e%2e/secret.png')).toBeUndefined();
+ });
+
+ it.each([
+ ['name%20with%20spaces.png', '/workspace/docs/name with spaces.png'],
+ ['name%20with%20multiple%20spaces.png', '/workspace/docs/name with multiple spaces.png'],
+ ['status%20%28final%29.png', '/workspace/docs/status (final).png'],
+ ['section%23one.png', '/workspace/docs/section#one.png'],
+ ['question%3Fmark.png', '/workspace/docs/question?mark.png'],
+ ['plain.png', '/workspace/docs/plain.png'],
+ ['nested/images/name%20with%20spaces.png', '/workspace/docs/nested/images/name with spaces.png'],
+ ['progress%25100.png', '/workspace/docs/progress%100.png']
+ ])('decodes local image URI path segments once: %s', (src, expectedPath) => {
+ const source = Uri.file('/workspace/docs/a.md');
+ const resolved = api.resolveImageUri(source as any, src);
+
+ expect(resolved?.fsPath).toBe(expectedPath);
+ });
+
+ it('does not decode local image paths twice', () => {
+ const source = Uri.file('/workspace/docs/a.md');
+ const resolved = api.resolveImageUri(source as any, 'literal%2520name.png');
+
+ expect(resolved?.fsPath).toBe('/workspace/docs/literal%20name.png');
+ });
+
+ it('resolves encoded file URIs through VS Code URI parsing', () => {
+ const source = Uri.file('/workspace/docs/a.md');
+ const resolved = api.resolveImageUri(
+ source as any,
+ 'file:///workspace/docs/name%20with%20spaces.png'
+ );
+
+ expect(resolved?.fsPath).toBe('/workspace/docs/name with spaces.png');
+ });
+
+ it.each([
+ 'nested%2Fsecret.png',
+ 'nested%5Csecret.png',
+ 'file:///workspace/docs/nested%2Fsecret.png',
+ 'file:///workspace/docs/nested%5Csecret.png',
+ 'malformed%E0%A4%A.png'
+ ])('rejects unsafe or malformed local image encoding without throwing: %s', (src) => {
+ const source = Uri.file('/workspace/docs/a.md');
+
+ expect(() => api.resolveImageUri(source as any, src)).not.toThrow();
+ expect(api.resolveImageUri(source as any, src)).toBeUndefined();
+ });
+
+ it('keeps remote HTTP image URLs out of local resolution', () => {
+ const source = Uri.file('/workspace/docs/a.md');
+
+ expect(api.resolveImageUri(source as any, 'http://example.com/name%20one.png')).toBeUndefined();
+ expect(api.resolveImageUri(source as any, 'https://example.com/name%20two.png')).toBeUndefined();
});
it('strips query strings and fragments before resolving local image paths', () => {
@@ -41,6 +95,12 @@ describe('linkResolver', () => {
'file:///workspace/docs/demo@2x.gif?cache=1#retina'
)?.toString()
).toBe('file:///workspace/docs/demo@2x.gif');
+ expect(
+ api.resolveImageUri(
+ source as any,
+ './icons%23set.svg?cache=1#logo'
+ )?.toString()
+ ).toBe('file:///workspace/docs/icons%23set.svg#logo');
});
it('preserves SVG fragments in resolved local image URIs', () => {
diff --git a/test/unit/markdownPipeline.test.ts b/test/unit/markdownPipeline.test.ts
index fafd187..1a39a30 100644
--- a/test/unit/markdownPipeline.test.ts
+++ b/test/unit/markdownPipeline.test.ts
@@ -54,6 +54,66 @@ describe('markdownPipeline', () => {
expect(result.html).toContain('footnote');
});
+ it('resolves encoded, angle-bracket, and reference-style local image destinations', () => {
+ const sourceUri = Uri.file('/workspace/docs/readme.md');
+ const webview = {
+ asWebviewUri(uri: { toString(): string }) {
+ return { toString: () => `vscode-webview://${uri.toString()}` };
+ }
+ };
+ const input = [
+ '',
+ '',
+ '![angle]()',
+ '',
+ '![reference][spaced-image]',
+ '',
+ '[spaced-image]: nested/name%20with%20spaces.png'
+ ].join('\n');
+
+ const result = renderMarkdown(input, {
+ sourceUri,
+ webview: webview as any,
+ allowHtml: true,
+ allowRemoteImages: false,
+ maxImageMB: 8
+ });
+
+ expect(
+ String(result.html).match(
+ /data-omv-local-src="file:\/\/\/workspace\/docs\/name%20with%20spaces\.png"/g
+ )
+ ).toHaveLength(2);
+ expect(result.html).toContain(
+ 'data-omv-local-src="file:///workspace/docs/nested/name%20with%20spaces.png"'
+ );
+ });
+
+ it('leaves allowed HTTP and HTTPS image URLs unchanged', () => {
+ const sourceUri = Uri.file('/workspace/docs/readme.md');
+ const webview = {
+ asWebviewUri(uri: { toString(): string }) {
+ return { toString: () => `vscode-webview://${uri.toString()}` };
+ }
+ };
+ const input = [
+ '',
+ ''
+ ].join('\n\n');
+
+ const result = renderMarkdown(input, {
+ sourceUri,
+ webview: webview as any,
+ allowHtml: true,
+ allowRemoteImages: true,
+ maxImageMB: 8
+ });
+
+ expect(result.html).toContain('src="http://example.com/name%20one.png"');
+ expect(result.html).toContain('src="https://example.com/name%20two.png"');
+ expect(result.html).not.toContain('data-omv-local-src=');
+ });
+
it('parses inline and multiline display math placeholders', () => {
const sourceUri = Uri.file('/workspace/docs/math.md');
const webview = {