diff --git a/models/renderhelper/repo_file_test.go b/models/renderhelper/repo_file_test.go
index 2f28d2c9065..12e1275b2b0 100644
--- a/models/renderhelper/repo_file_test.go
+++ b/models/renderhelper/repo_file_test.go
@@ -9,6 +9,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/markup/markdown"
+ "gitea.dev/modules/test"
_ "gitea.dev/modules/markup/orgmode"
@@ -80,6 +81,23 @@ func TestRepoFile(t *testing.T) {
`, rendered)
})
+
+ t.Run("PictureSource", func(t *testing.T) {
+ rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}).
+ WithMarkupType(markdown.MarkupName)
+ rendered, err := testRenderString(rctx, `
`)
+ assert.NoError(t, err)
+ assert.Equal(t, test.NormalizeHTMLSpaces(`
+
+
+
+
+
+
+
+
+`), test.NormalizeHTMLSpaces(rendered))
+ })
}
func TestRepoFileOrgMode(t *testing.T) {
diff --git a/modules/htmlutil/html_srcset.go b/modules/htmlutil/html_srcset.go
new file mode 100644
index 00000000000..b9913a06322
--- /dev/null
+++ b/modules/htmlutil/html_srcset.go
@@ -0,0 +1,105 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package htmlutil
+
+import (
+ "strings"
+)
+
+type SrcSetCandidate struct {
+ SrcLink string
+ SrcDesc []string
+}
+
+const (
+ asciiSpaces = " \t\n\r\f"
+ asciiSpacesAndComma = " \t\n\r\f,"
+)
+
+func ParseSrcset(srcset string) (candidates []SrcSetCandidate) {
+ // ref: https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
+ // It can't simply use "split" because comma can appear in URLs and descriptors can be parenthesized and contain commas.
+ extractLink := func() (string, bool) {
+ srcset = strings.TrimLeft(srcset, asciiSpacesAndComma)
+ if srcset == "" {
+ return "", false
+ }
+ end := strings.IndexAny(srcset, asciiSpaces)
+ if end == -1 {
+ end = len(srcset)
+ }
+ link := srcset[:end]
+ srcset = srcset[end:]
+ if strings.HasSuffix(link, ",") {
+ return strings.TrimRight(link, ","), false
+ }
+ return link, true
+ }
+ extractDesc := func() (string, bool) {
+ srcset = strings.TrimLeft(srcset, asciiSpaces)
+ if srcset == "" {
+ return "", false
+ }
+ inParentheses := false
+ for i := range len(srcset) {
+ c := srcset[i]
+ if inParentheses {
+ inParentheses = c != ')'
+ continue
+ }
+ if c == '(' {
+ inParentheses = true
+ continue
+ }
+ if strings.IndexByte(asciiSpacesAndComma, c) != -1 {
+ desc := srcset[:i]
+ srcset = srcset[i+1:]
+ return desc, c != ','
+ }
+ }
+ desc := srcset
+ srcset = ""
+ return desc, false
+ }
+
+ for {
+ link, hasDesc := extractLink()
+ if link == "" {
+ break
+ }
+ var descriptors []string
+ if hasDesc {
+ for {
+ desc, hasMoreDesc := extractDesc()
+ if desc != "" {
+ descriptors = append(descriptors, desc)
+ }
+ if !hasMoreDesc {
+ break
+ }
+ }
+ }
+ candidates = append(candidates, SrcSetCandidate{SrcLink: link, SrcDesc: descriptors})
+ }
+ return candidates
+}
+
+func EncodeSrcset(candidates []SrcSetCandidate) string {
+ var sb strings.Builder
+ for i, c := range candidates {
+ if i != 0 {
+ sb.WriteString(", ")
+ }
+ link := c.SrcLink
+ if prefix, ok := strings.CutSuffix(link, ","); ok {
+ link = prefix + "%2C"
+ }
+ sb.WriteString(link)
+ for _, d := range c.SrcDesc {
+ sb.WriteByte(' ')
+ sb.WriteString(d)
+ }
+ }
+ return sb.String()
+}
diff --git a/modules/htmlutil/html_srcset_test.go b/modules/htmlutil/html_srcset_test.go
new file mode 100644
index 00000000000..41122a6a136
--- /dev/null
+++ b/modules/htmlutil/html_srcset_test.go
@@ -0,0 +1,81 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package htmlutil
+
+import (
+ "testing"
+
+ "gitea.dev/modules/util"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSrcset(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ input string
+ candidates []SrcSetCandidate
+ encoded string
+ }{
+ {
+ name: "bare URL",
+ input: "image.png",
+ candidates: []SrcSetCandidate{{SrcLink: "image.png"}},
+ },
+ {
+ name: "bare URLs",
+ input: "a.png, b.png , c.png,",
+ candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png"}, {SrcLink: "c.png"}},
+ encoded: "a.png, b.png, c.png",
+ },
+ {
+ name: "empty candidates",
+ input: ", ,\ta.png 1x,, ,b.png 2x, ,",
+ candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
+ encoded: "a.png 1x, b.png 2x",
+ },
+ {
+ name: "trailing commas in URL",
+ input: "a.png,, b.png 2x",
+ candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
+ encoded: "a.png, b.png 2x",
+ },
+ {
+ name: "comma in URL",
+ input: "comma,in,url.png",
+ candidates: []SrcSetCandidate{{SrcLink: "comma,in,url.png"}},
+ },
+ {
+ name: "embedded data URL",
+ input: "data:image/png;base64,abcd 1x, image.png 2x",
+ candidates: []SrcSetCandidate{{SrcLink: "data:image/png;base64,abcd", SrcDesc: []string{"1x"}}, {SrcLink: "image.png", SrcDesc: []string{"2x"}}},
+ },
+ {
+ name: "parenthesized descriptor",
+ input: "a.png (x,y) 1x, b.png 2x",
+ candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"(x,y)", "1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ candidates := ParseSrcset(test.input)
+ assert.Equal(t, test.candidates, candidates)
+ encoded := EncodeSrcset(candidates)
+ assert.Equal(t, util.IfZero(test.encoded, test.input), encoded)
+ })
+ }
+
+ t.Run("encode trailing commas", func(t *testing.T) {
+ encoded := EncodeSrcset([]SrcSetCandidate{
+ {SrcLink: "image,"},
+ {SrcLink: "image,,", SrcDesc: []string{"2x"}},
+ {SrcLink: "image.svg?variant=light,", SrcDesc: []string{"3x"}},
+ })
+ assert.Equal(t, "image%2C, image,%2C 2x, image.svg?variant=light%2C 3x", encoded)
+ assert.Equal(t, []SrcSetCandidate{
+ {SrcLink: "image%2C"},
+ {SrcLink: "image,%2C", SrcDesc: []string{"2x"}},
+ {SrcLink: "image.svg?variant=light%2C", SrcDesc: []string{"3x"}},
+ }, ParseSrcset(encoded))
+ })
+}
diff --git a/modules/markup/camo.go b/modules/markup/camo.go
index 6c10dee6129..61b5789afd7 100644
--- a/modules/markup/camo.go
+++ b/modules/markup/camo.go
@@ -36,7 +36,7 @@ func b64encode(data []byte) string {
func camoHandleLink(link string) string {
if setting.Camo.Enabled {
lnkURL, err := url.Parse(link)
- if err == nil && lnkURL.IsAbs() && !strings.HasPrefix(link, setting.AppURL) &&
+ if err == nil && (lnkURL.Scheme == "http" || lnkURL.Scheme == "https") && !strings.HasPrefix(link, setting.AppURL) &&
(setting.Camo.Always || lnkURL.Scheme != "https") {
return CamoEncode(link)
}
diff --git a/modules/markup/camo_test.go b/modules/markup/camo_test.go
index d432b9df4f0..f6afe9ec6e8 100644
--- a/modules/markup/camo_test.go
+++ b/modules/markup/camo_test.go
@@ -15,8 +15,11 @@ func TestCamoHandleLink(t *testing.T) {
setting.AppURL = "https://gitea.com"
// Test media proxy
setting.Camo.Enabled = true
+ setting.Camo.Always = false
setting.Camo.ServerURL = "https://image.proxy"
setting.Camo.HMACKey = "geheim"
+ dataURL := "data:image/png;base64,i//V"
+ assert.Equal(t, dataURL, camoHandleLink(dataURL))
assert.Equal(t,
"https://gitea.com/img.jpg",
@@ -29,6 +32,7 @@ func TestCamoHandleLink(t *testing.T) {
camoHandleLink("http://testimages.org/img.jpg"))
setting.Camo.Always = true
+ assert.Equal(t, dataURL, camoHandleLink(dataURL))
assert.Equal(t,
"https://gitea.com/img.jpg",
camoHandleLink("https://gitea.com/img.jpg"))
diff --git a/modules/markup/html.go b/modules/markup/html.go
index f64114226e1..4731ae91e93 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -355,6 +355,8 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
return visitNodeImg(ctx, node)
} else if node.Data == "video" {
return visitNodeVideo(ctx, node)
+ } else if node.Data == "source" {
+ return visitNodePictureSource(ctx, node)
}
if node.Data == "a" {
@@ -363,10 +365,11 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
// because the content there is not processable, for example: the content is a commit id or a full URL.
procs = emojiProcessors
}
+ next := node.NextSibling
for n := node.FirstChild; n != nil; {
n = visitNode(ctx, procs, n)
}
- return node.NextSibling
+ return next
}
// createKeyword() renders a highlighted version of an action keyword
diff --git a/modules/markup/html_node.go b/modules/markup/html_node.go
index 0447b6d8a39..0226616a43a 100644
--- a/modules/markup/html_node.go
+++ b/modules/markup/html_node.go
@@ -6,6 +6,7 @@ package markup
import (
"strings"
+ "gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup/common"
"golang.org/x/net/html"
@@ -127,6 +128,10 @@ func processNodeA(ctx *RenderContext, node *html.Node) {
}
}
+func resolveMediaLinkWithCamo(ctx *RenderContext, link string) string {
+ return camoHandleLink(ctx.RenderHelper.ResolveLink(link, LinkTypeMedia))
+}
+
func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
next = img.NextSibling
attrSrc, hasLazy := "", false
@@ -152,19 +157,21 @@ func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
cnt++
}
if isLinkable {
+ linkNode := img
+ if img.Parent.Data == "picture" {
+ linkNode = img.Parent
+ }
wrapper := &html.Node{Type: html.ElementNode, Data: "a", Attr: []html.Attribute{
{Key: "href", Val: ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeDefault)},
{Key: "target", Val: "_blank"},
}}
- parent := img.Parent
- imgNext := img.NextSibling
- parent.RemoveChild(img)
- parent.InsertBefore(wrapper, imgNext)
- wrapper.AppendChild(img)
+ parent := linkNode.Parent
+ linkNext := linkNode.NextSibling
+ parent.RemoveChild(linkNode)
+ parent.InsertBefore(wrapper, linkNext)
+ wrapper.AppendChild(linkNode)
}
-
- imgAttr.Val = ctx.RenderHelper.ResolveLink(imgSrcOrigin, LinkTypeMedia)
- imgAttr.Val = camoHandleLink(imgAttr.Val)
+ imgAttr.Val = resolveMediaLinkWithCamo(ctx, imgSrcOrigin)
img.Attr[i] = imgAttr
}
if !RenderBehaviorForTesting.DisableAdditionalAttributes && !hasLazy && !strings.HasPrefix(attrSrc, "data:") {
@@ -179,9 +186,24 @@ func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
if attr.Key != "src" {
continue
}
- attr.Val = ctx.RenderHelper.ResolveLink(attr.Val, LinkTypeMedia)
- attr.Val = camoHandleLink(attr.Val)
+ attr.Val = resolveMediaLinkWithCamo(ctx, attr.Val)
node.Attr[i] = attr
}
return next
}
+
+func visitNodePictureSource(ctx *RenderContext, node *html.Node) (next *html.Node) {
+ next = node.NextSibling
+ for attrIdx, attr := range node.Attr {
+ if attr.Key != "srcset" {
+ continue
+ }
+ srcsetCandidates := htmlutil.ParseSrcset(attr.Val)
+ for i, candidate := range srcsetCandidates {
+ srcsetCandidates[i].SrcLink = resolveMediaLinkWithCamo(ctx, candidate.SrcLink)
+ }
+ attr.Val = htmlutil.EncodeSrcset(srcsetCandidates)
+ node.Attr[attrIdx] = attr
+ }
+ return next
+}
diff --git a/modules/markup/render_link.go b/modules/markup/render_link.go
index c7470cda7e6..273146ac259 100644
--- a/modules/markup/render_link.go
+++ b/modules/markup/render_link.go
@@ -42,7 +42,7 @@ func resolveLinkRelative(ctx context.Context, base, cur, link string, absolute b
finalLink += "?" + linkURL.RawQuery
}
if linkURL.Fragment != "" {
- finalLink += "#" + linkURL.Fragment
+ finalLink += "#" + linkURL.EscapedFragment()
}
}
diff --git a/modules/markup/render_link_test.go b/modules/markup/render_link_test.go
index 1ebe60688a3..57485ebf55a 100644
--- a/modules/markup/render_link_test.go
+++ b/modules/markup/render_link_test.go
@@ -20,7 +20,7 @@ func TestResolveLinkRelative(t *testing.T) {
assert.Equal(t, "/a/c", resolveLinkRelative(ctx, "/a", "b", "/c", false))
assert.Equal(t, "/a/c#id", resolveLinkRelative(ctx, "/a", "b", "/c#id", false))
assert.Equal(t, "/a/%2f?k=/", resolveLinkRelative(ctx, "/a", "b", "/%2f/?k=/", false))
- assert.Equal(t, "/a/b/c?k=v#id", resolveLinkRelative(ctx, "/a", "b", "c/?k=v#id", false))
+ assert.Equal(t, "/a/b/c?k=v#with%20space", resolveLinkRelative(ctx, "/a", "b", "c/?k=v#with%20space", false))
assert.Equal(t, "%invalid", resolveLinkRelative(ctx, "/a", "b", "%invalid", false))
assert.Equal(t, "http://localhost:3000/a", resolveLinkRelative(ctx, "/a", "", "", true))