From 625df900c4854ca20cce95785b2eaf26edccf44c Mon Sep 17 00:00:00 2001 From: Bowlerr Date: Sun, 12 Jul 2026 13:38:58 +0100 Subject: [PATCH] feat: add support for local image paths with spaces and enhance image resolution logic --- .e2e-workspace/assets/name with spaces.svg | 3 + .e2e-workspace/assets/normal-image.svg | 3 + .e2e-workspace/image-paths.md | 19 ++++++ .../preview/markdown/linkResolver.ts | 38 ++++++++++-- test/e2e/preview.e2e.test.ts | 7 +++ test/unit/helpers/vscodeMock.ts | 19 ++++-- test/unit/linkResolver.test.ts | 60 +++++++++++++++++++ test/unit/markdownPipeline.test.ts | 60 +++++++++++++++++++ 8 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 .e2e-workspace/assets/name with spaces.svg create mode 100644 .e2e-workspace/assets/normal-image.svg create mode 100644 .e2e-workspace/image-paths.md 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 + +![Normal image](./assets/normal-image.svg) + +## URL-encoded spaces + +![Encoded spaces](./assets/name%20with%20spaces.svg) + +## Angle-bracket destination + +![Angle-bracket spaces](<./assets/name with spaces.svg>) + +## 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 = [ + '![encoded](name%20with%20spaces.png)', + '', + '![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 = [ + '![http](http://example.com/name%20one.png)', + '![https](https://example.com/name%20two.png)' + ].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 = {