Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/mdv-gui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (a *App) load(path string) error {
if err != nil {
return err
}
htmlBytes, err := render.ToHTML(b, a.cfg.GUITheme, a.cfg.GUIThemeLight, a.cfg.GUIThemeDark, a.cfg.GUIWidth)
htmlBytes, err := render.ToHTML(b, a.cfg.GUITheme, a.cfg.GUIThemeLight, a.cfg.GUIThemeDark, a.cfg.GUIWidth, path)
if err != nil {
return err
}
Expand Down
25 changes: 25 additions & 0 deletions examples/images-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Image Rendering Test

This document tests local image rendering in mdv-gui.

## Local Image References

### Relative Path
Here's an image using a relative path:

![Test Icon](images/test-icon.png)

### Another Reference
This should display the mdv app icon:

![MDV Icon](./images/test-icon.png)

## External Images

For comparison, here's an external image (should work without changes):

![External](https://avatars.githubusercontent.com/u/764612?s=80&v=4)

## Test Results

If you can see the local images above in mdv-gui, the feature is working correctly!
Binary file added examples/images/test-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion internal/render/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ func TestToHTMLMatchesGolden(t *testing.T) {
src := []byte("# Title\n\nContent")
themePath := filepath.Join("testdata", "html_theme.css")

out, err := ToHTML(src, themePath, "", "", "800")
out, err := ToHTML(src, themePath, "", "", "800", "")
if err != nil {
t.Fatalf("ToHTML returned error: %v", err)
}
Expand Down
82 changes: 80 additions & 2 deletions internal/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package render

import (
"bytes"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
Expand Down Expand Up @@ -122,7 +124,76 @@ func ResolveTheme(theme, themeLight, themeDark string) string {
return detected
}

func ToHTML(src []byte, theme, themeLight, themeDark, width string) ([]byte, error) {
// processLocalImages processes HTML and converts relative image paths to data URIs.
// basePath is the directory of the markdown file, used to resolve relative paths.
func processLocalImages(htmlContent string, basePath string) string {
// Regex to match img tags with src attributes
imgRegex := regexp.MustCompile(`<img\s+([^>]*?)src=["']([^"']+)["']([^>]*?)>`)

return imgRegex.ReplaceAllStringFunc(htmlContent, func(match string) string {
// Extract the src attribute value
srcRegex := regexp.MustCompile(`src=["']([^"']+)["']`)
srcMatches := srcRegex.FindStringSubmatch(match)
if len(srcMatches) < 2 {
return match
}

src := srcMatches[1]

// Skip if it's already a data URI or absolute URL
if strings.HasPrefix(src, "data:") ||
strings.HasPrefix(src, "http://") ||
strings.HasPrefix(src, "https://") ||
strings.HasPrefix(src, "//") {
return match
}

// Resolve relative path to absolute path
var absPath string
if filepath.IsAbs(src) {
absPath = src
} else {
absPath = filepath.Join(basePath, src)
}

// Read the image file
imgData, err := os.ReadFile(absPath)
if err != nil {
// If we can't read the file, return the original tag
return match
}

// Determine MIME type based on file extension
ext := strings.ToLower(filepath.Ext(absPath))
mimeType := "image/png" // default
switch ext {
case ".jpg", ".jpeg":
mimeType = "image/jpeg"
case ".png":
mimeType = "image/png"
case ".gif":
mimeType = "image/gif"
case ".svg":
mimeType = "image/svg+xml"
case ".webp":
mimeType = "image/webp"
case ".bmp":
mimeType = "image/bmp"
case ".ico":
mimeType = "image/x-icon"
}

// Encode to base64
encoded := base64.StdEncoding.EncodeToString(imgData)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)

// Replace src attribute with data URI
newMatch := srcRegex.ReplaceAllString(match, fmt.Sprintf(`src="%s"`, dataURI))
return newMatch
})
}

func ToHTML(src []byte, theme, themeLight, themeDark, width, mdFilePath string) ([]byte, error) {
// Convert markdown to HTML
var buf bytes.Buffer
if err := md.Convert(src, &buf); err != nil {
Expand Down Expand Up @@ -152,6 +223,13 @@ func ToHTML(src []byte, theme, themeLight, themeDark, width string) ([]byte, err
widthCSS = fmt.Sprintf(".markdown-body { max-width: %s; margin-left: auto !important; margin-right: auto !important; }\n", maxWidth)
}

// Process local images if we have a file path
htmlContent := buf.String()
if mdFilePath != "" {
basePath := filepath.Dir(mdFilePath)
htmlContent = processLocalImages(htmlContent, basePath)
}

// Wrap the HTML with the CSS theme and markdown-body container
var output bytes.Buffer
output.WriteString("<style>\n")
Expand All @@ -161,7 +239,7 @@ func ToHTML(src []byte, theme, themeLight, themeDark, width string) ([]byte, err
output.WriteString(widthCSS) // Width CSS comes last to override any theme margins
output.WriteString("</style>\n")
output.WriteString(`<div class="markdown-body">` + "\n")
output.Write(buf.Bytes())
output.WriteString(htmlContent)
output.WriteString("\n</div>")

return output.Bytes(), nil
Expand Down
2 changes: 1 addition & 1 deletion internal/render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func TestToHTMLWrapsOutputWithThemeAndWidth(t *testing.T) {
t.Setenv("COLORFGBG", "15;0")

src := []byte("# Title\n\nContent")
html, err := ToHTML(src, "auto", "", "", "narrow")
html, err := ToHTML(src, "auto", "", "", "narrow", "")
if err != nil {
t.Fatalf("ToHTML returned error: %v", err)
}
Expand Down
Loading