Add SVG parser (#346)

This commit is contained in:
Thomas Miceli 2024-10-14 21:20:56 +02:00 committed by GitHub
parent 3068588111
commit df226cbd99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 267 additions and 76 deletions

2
go.mod
View file

@ -97,7 +97,7 @@ require (
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.etcd.io/bbolt v1.3.10 // indirect go.etcd.io/bbolt v1.3.10 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.20.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.23.0 // indirect golang.org/x/sys v0.23.0 // indirect

4
go.sum
View file

@ -232,8 +232,8 @@ golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=

View file

@ -3,6 +3,7 @@ package render
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/base64"
"fmt" "fmt"
"github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/formatters/html"
@ -11,6 +12,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"path"
"sync" "sync"
) )
@ -28,18 +30,22 @@ type RenderedGist struct {
} }
func HighlightFile(file *git.File) (RenderedFile, error) { func HighlightFile(file *git.File) (RenderedFile, error) {
rendered := RenderedFile{
File: file,
}
style := newStyle() style := newStyle()
lexer := newLexer(file.Filename) lexer := newLexer(file.Filename)
if lexer.Config().Name == "markdown" { if lexer.Config().Name == "markdown" {
return MarkdownFile(file) return MarkdownFile(file)
} }
if lexer.Config().Name == "XML" && path.Ext(file.Filename) == ".svg" {
return RenderSvgFile(file), nil
}
formatter := html.New(html.WithClasses(true), html.PreventSurroundingPre(true)) formatter := html.New(html.WithClasses(true), html.PreventSurroundingPre(true))
rendered := RenderedFile{
File: file,
}
iterator, err := lexer.Tokenise(nil, file.Content+"\n") iterator, err := lexer.Tokenise(nil, file.Content+"\n")
if err != nil { if err != nil {
return rendered, err return rendered, err
@ -140,6 +146,20 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
return rendered, err return rendered, err
} }
func RenderSvgFile(file *git.File) RenderedFile {
rendered := RenderedFile{
File: file,
}
encoded := base64.StdEncoding.EncodeToString([]byte(file.Content))
content := `<img src="data:image/svg+xml;base64,` + encoded + `" />`
rendered.HTML = content
rendered.Type = "SVG"
return rendered
}
func parseFileTypeName(config chroma.Config) string { func parseFileTypeName(config chroma.Config) string {
fileType := config.Name fileType := config.Name
if fileType == "fallback" || fileType == "plaintext" { if fileType == "fallback" || fileType == "plaintext" {

View file

@ -1,24 +1,17 @@
package render package render
import ( import (
"bufio"
"bytes" "bytes"
"github.com/Kunde21/markdownfmt/v3"
"github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
emoji "github.com/yuin/goldmark-emoji" emoji "github.com/yuin/goldmark-emoji"
highlighting "github.com/yuin/goldmark-highlighting/v2" highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
astex "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"go.abhg.dev/goldmark/mermaid" "go.abhg.dev/goldmark/mermaid"
"strconv"
) )
func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) { func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
@ -33,7 +26,7 @@ func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
func MarkdownFile(file *git.File) (RenderedFile, error) { func MarkdownFile(file *git.File) (RenderedFile, error) {
var buf bytes.Buffer var buf bytes.Buffer
err := newMarkdown().Convert([]byte(file.Content), &buf) err := newMarkdownWithSvgExtension().Convert([]byte(file.Content), &buf)
return RenderedFile{ return RenderedFile{
File: file, File: file,
@ -43,77 +36,34 @@ func MarkdownFile(file *git.File) (RenderedFile, error) {
} }
func MarkdownString(content string) (string, error) { func MarkdownString(content string) (string, error) {
var buf bytes.Buffer var buf bytes.Buffer
err := newMarkdown().Convert([]byte(content), &buf) err := newMarkdownWithSvgExtension().Convert([]byte(content), &buf)
return buf.String(), err return buf.String(), err
} }
func newMarkdown() goldmark.Markdown { func newMarkdown(extraExtensions ...goldmark.Extender) goldmark.Markdown {
return goldmark.New( extensions := []goldmark.Extender{
goldmark.WithExtensions( extension.GFM,
extension.GFM, highlighting.NewHighlighting(
highlighting.NewHighlighting( highlighting.WithStyle("catppuccin-latte"),
highlighting.WithStyle("catppuccin-latte"), highlighting.WithFormatOptions(html.WithClasses(true)),
highlighting.WithFormatOptions(html.WithClasses(true))),
emoji.Emoji,
&mermaid.Extender{},
), ),
emoji.Emoji,
&mermaid.Extender{},
}
extensions = append(extensions, extraExtensions...)
return goldmark.New(
goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions( goldmark.WithParserOptions(
parser.WithASTTransformers( parser.WithASTTransformers(
util.Prioritized(&CheckboxTransformer{}, 10000), util.Prioritized(&checkboxTransformer{}, 10000),
), ),
), ),
) )
} }
type CheckboxTransformer struct{} func newMarkdownWithSvgExtension() goldmark.Markdown {
return newMarkdown(&svgToImgBase64{})
func (t *CheckboxTransformer) Transform(node *ast.Document, _ text.Reader, _ parser.Context) {
i := 1
err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if _, ok := n.(*astex.TaskCheckBox); ok {
listitem := n.Parent().Parent()
listitem.SetAttribute([]byte("data-checkbox-nb"), []byte(strconv.Itoa(i)))
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
log.Err(err)
}
}
func Checkbox(content string, checkboxNb int) (string, error) {
buf := bytes.Buffer{}
w := bufio.NewWriter(&buf)
source := []byte(content)
markdown := markdownfmt.NewGoldmark()
reader := text.NewReader(source)
document := markdown.Parser().Parse(reader)
i := 1
err := ast.Walk(document, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if listItem, ok := n.(*astex.TaskCheckBox); ok {
if i == checkboxNb {
listItem.IsChecked = !listItem.IsChecked
}
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
return "", err
}
if err = markdown.Renderer().Render(w, source, document); err != nil {
return "", err
}
_ = w.Flush()
return buf.String(), nil
} }

View file

@ -0,0 +1,65 @@
package render
import (
"bufio"
"bytes"
"github.com/Kunde21/markdownfmt/v3"
"github.com/rs/zerolog/log"
"github.com/yuin/goldmark/ast"
astex "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"strconv"
)
type checkboxTransformer struct{}
func (t *checkboxTransformer) Transform(node *ast.Document, _ text.Reader, _ parser.Context) {
i := 1
err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if _, ok := n.(*astex.TaskCheckBox); ok {
listitem := n.Parent().Parent()
listitem.SetAttribute([]byte("data-checkbox-nb"), []byte(strconv.Itoa(i)))
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
log.Err(err)
}
}
func Checkbox(content string, checkboxNb int) (string, error) {
buf := bytes.Buffer{}
w := bufio.NewWriter(&buf)
source := []byte(content)
markdown := markdownfmt.NewGoldmark()
reader := text.NewReader(source)
document := markdown.Parser().Parse(reader)
i := 1
err := ast.Walk(document, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if listItem, ok := n.(*astex.TaskCheckBox); ok {
if i == checkboxNb {
listItem.IsChecked = !listItem.IsChecked
}
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
return "", err
}
if err = markdown.Renderer().Render(w, source, document); err != nil {
return "", err
}
_ = w.Flush()
return buf.String(), nil
}

View file

@ -0,0 +1,141 @@
package render
import (
"bytes"
"encoding/base64"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"regexp"
)
var svgRegex = regexp.MustCompile(`(?i)^[ ]{0,3}<(svg)(?:\s.*|>.*|/>.*|)(?:\r\n|\n)?$`)
type svgToImgBase64 struct{}
func (e *svgToImgBase64) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithBlockParsers(
util.Prioritized(newSvgParser(), 1),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(newSvgRenderer(), 1),
))
}
// -- SVG Block -- //
type svgBlock struct {
ast.BaseBlock
}
func (n *svgBlock) IsRaw() bool {
return true
}
func (n *svgBlock) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
var svgBlockKind = ast.NewNodeKind("SVG")
func (n *svgBlock) Kind() ast.NodeKind {
return svgBlockKind
}
func newSvgBlock() *svgBlock {
return &svgBlock{
BaseBlock: ast.BaseBlock{},
}
}
// -- SVG Parser -- //
type svgParser struct {
}
var defaultSvgParser = &svgParser{}
func newSvgParser() parser.BlockParser {
return defaultSvgParser
}
func (b *svgParser) Trigger() []byte {
return []byte{'<'}
}
func (b *svgParser) Open(parent ast.Node, reader text.Reader, _ parser.Context) (ast.Node, parser.State) {
var node *svgBlock
line, segment := reader.PeekLine()
if !bytes.HasPrefix(line, []byte("<svg")) {
return nil, parser.None
}
if svgRegex.Match(line) {
node = newSvgBlock()
}
if node != nil {
reader.Advance(segment.Len() - util.TrimRightSpaceLength(line))
node.Lines().Append(segment)
return node, parser.NoChildren
}
return nil, parser.None
}
func (b *svgParser) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State {
line, segment := reader.PeekLine()
if util.IsBlank(line) {
return parser.Close
}
if !bytes.HasSuffix(util.TrimRightSpace(line), []byte("</svg>")) {
node.Lines().Append(segment)
return parser.Continue | parser.NoChildren
}
node.Lines().Append(segment)
reader.Advance(segment.Len())
return parser.Close
}
func (b *svgParser) Close(_ ast.Node, _ text.Reader, _ parser.Context) {}
func (b *svgParser) CanInterruptParagraph() bool {
return true
}
func (b *svgParser) CanAcceptIndentedLine() bool {
return false
}
// -- SVG Renderer -- //
type svgRenderer struct{}
func newSvgRenderer() renderer.NodeRenderer {
return &svgRenderer{}
}
func (r *svgRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(svgBlockKind, r.renderSVG)
}
func (r *svgRenderer) renderSVG(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
rawHTML := node.(*svgBlock)
var svgContent []byte
for i := 0; i < rawHTML.Lines().Len(); i++ {
segment := rawHTML.Lines().At(i)
svgContent = append(svgContent, segment.Value(source)...)
}
encoded := base64.StdEncoding.EncodeToString(svgContent)
imgTag := `<img src="data:image/svg+xml;base64,` + encoded + `" />`
_, _ = w.Write([]byte(imgTag))
return ast.WalkContinue, nil
}

View file

@ -65,6 +65,9 @@ var (
"isCsv": func(i string) bool { "isCsv": func(i string) bool {
return strings.ToLower(filepath.Ext(i)) == ".csv" return strings.ToLower(filepath.Ext(i)) == ".csv"
}, },
"isSvg": func(i string) bool {
return strings.ToLower(filepath.Ext(i)) == ".svg"
},
"csvFile": func(file *git.File) *git.CsvFile { "csvFile": func(file *git.File) *git.CsvFile {
if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" { if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" {
return nil return nil

4
public/embed.scss vendored
View file

@ -107,6 +107,10 @@ dl.dl-config dd {
@apply overflow-auto whitespace-pre; @apply overflow-auto whitespace-pre;
} }
.markdown-body img {
@apply bg-transparent dark:bg-transparent;
}
.chroma.preview.markdown pre code { .chroma.preview.markdown pre code {
@apply p-4; @apply p-4;
} }

4
public/style.css vendored
View file

@ -167,6 +167,10 @@ dl.dl-config dd {
@apply overflow-auto whitespace-pre !important; @apply overflow-auto whitespace-pre !important;
} }
.markdown-body img {
@apply bg-transparent dark:bg-transparent !important;
}
.chroma.preview.markdown pre code { .chroma.preview.markdown pre code {
@apply p-4 !important; @apply p-4 !important;
} }

View file

@ -68,6 +68,8 @@
</table> </table>
{{ else if isMarkdown $file.Filename }} {{ else if isMarkdown $file.Filename }}
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div> <div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
{{ else if isSvg $file.Filename }}
<div class="p-8 flex justify-center">{{ $file.HTML | safe }}</div>
{{ else }} {{ else }}
<div class="code"> <div class="code">
{{ $fileslug := slug $file.Filename }} {{ $fileslug := slug $file.Filename }}

View file

@ -32,6 +32,8 @@
</table> </table>
{{ else if isMarkdown $file.Filename }} {{ else if isMarkdown $file.Filename }}
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div> <div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
{{ else if isSvg $file.Filename }}
<div class="p-8 flex justify-center">{{ $file.HTML | safe }}</div>
{{ else }} {{ else }}
<div class="code dark:bg-gray-900"> <div class="code dark:bg-gray-900">
{{ $fileslug := slug $file.Filename }} {{ $fileslug := slug $file.Filename }}