Beoran 2 years ago
parent
commit
c9167fdc5d
6 changed files with 313 additions and 67 deletions
  1. 12 2
      README.md
  2. 7 1
      go.mod
  3. 148 5
      mvg/parser.go
  4. 14 0
      svg/style.go
  5. 70 32
      svg/svg.go
  6. 62 27
      testdata/yellow_circle.svg

+ 12 - 2
README.md

@@ -1,4 +1,14 @@
 # ebisvg
 
-SVG 1.1 loader and renderer for Ebiten. Also accepts Inkscape SVG.
-Support for features varies.
+This was supposed to be an SVG 1.1 loader and renderer for Ebiten.
+But XML makes me angry, SVG is extremely complex, and the XML parser of Go
+language standard library is no fun to use at all.
+
+Then I discovered that  the inagemagick tool supports a text based vector
+format called Magic Vector Graphics, and it can convert SVG to MVG.
+
+So I decided to implement an MVG interpreter for Ebiten in stead.
+This also has the advantage that you can "draw" vector images easily
+with a text editor or interactively from the command line.
+
+

+ 7 - 1
go.mod

@@ -3,6 +3,12 @@ module src.eruta.nl/beoran/ebisvg
 go 1.16
 
 require (
-	github.com/hajimehoshi/ebiten/v2 v2.1.7 // indirect
+	github.com/gofrs/flock v0.8.0 // indirect
+	github.com/hajimehoshi/ebiten/v2 v2.2.0-rc.2 // indirect
+	github.com/hajimehoshi/oto v0.7.1 // indirect
+	golang.org/x/exp v0.0.0-20210916165020-5cb4fee858ee // indirect
 	golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
+	golang.org/x/mobile v0.0.0-20210924032853-1c027f395ef7 // indirect
+	golang.org/x/sys v0.0.0-20211002104244-808efd93c36d // indirect
+	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 )

+ 148 - 5
mvg/parser.go

@@ -1,7 +1,10 @@
 package mvg
 
+import "encoding/hex"
 import "fmt"
 import "strconv"
+import "strings"
+import "golang.org/x/image/colornames"
 
 /* LL(1) syntax.
 
@@ -167,6 +170,33 @@ func (p *Parser) ParseNumber() Value {
 
 type Ref string
 
+func IsRef(r rune, i int) bool {
+	if (i == 0) && (r == 'u') {
+		return true
+	} else if (i == 1) && (r == 'r') {
+		return true
+	} else if (i == 2) && (r == 'l') {
+		return true
+	} else if (i == 3) && (r == '(') {
+		return true
+	} else if (i == 4) && (r == '#') {
+		return true
+	} else if (i > 5) && (r == ')') {
+		return true
+	}
+	return IsWord(r, i)
+}
+
+func (p *Parser) ParseRef() Value {
+	prefix := string(p.Buffer[p.Index : p.Index+4])
+	if prefix != "url(#" {
+		return nil
+	}
+	return p.ParseWhile(IsRef, func(res string) Value {
+		return Variable(res[5 : len(res)-1]) // chop off the url(#)
+	})
+}
+
 func (r Ref) Eval(gc *GraphicContext) Value {
 	var ok bool
 	var val DefValue
@@ -181,16 +211,129 @@ func (r Ref) Eval(gc *GraphicContext) Value {
 
 type String string
 
-func (p Parser) IsString(r rune, i int) bool {
-	if (i == 0) && (r == '"') {
+func IsStringFor(p Parser, between rune) func(r rune, i int) bool {
+	return func(r rune, i int) bool {
+		if (i == 0) && (r == between) {
+			return true
+		} else if r == between {
+			return p.Buffer[p.Index+i-1] != '\\'
+		} else {
+			return true
+		}
+	}
+}
+
+var StringEscaper = strings.NewReplacer(
+	"\\\\", "\\",
+	"\\n", "\n",
+	"\\r", "\r",
+	"\\t", "\t",
+)
+
+func (s String) Eval(gc *GraphicContext) Value {
+	return s
+}
+
+func (p *Parser) ParseString() Value {
+	between := p.Peek()
+	if between != '\'' && between != '"' && between != '`' {
+		return nil
+	}
+	return p.ParseWhile(IsStringFor(*p, between), func(res string) Value {
+		mid := res[1:(len(res) - 2)]
+		return String(StringEscaper.Replace(mid))
+	})
+}
+
+func IsColor(r rune, i int) bool {
+	if (i == 0) && (r == '#') {
 		return true
-	} else if r == '"' {
-		return p.Buffer[p.Index+i-1] != '\\'
+	}
+	return (r >= '0' && r <= '9') ||
+		(r >= 'a' && r <= 'f') ||
+		(r >= 'A' && r <= 'F')
+}
+
+func StringToColor(str string) (Color, error) {
+	if str[0] == '#' {
+		sub := str[1:len(str)]
+		switch len(sub) {
+		case 3:
+			sub = sub[0:0] + sub[0:0] + sub[1:1] + sub[1:1] + sub[2:2] + sub[2:2] + "00"
+		case 4:
+			sub = sub[0:0] + sub[0:0] + sub[1:1] + sub[1:1] + sub[2:2] + sub[2:2] + sub[3:3] + sub[3:3]
+		case 6:
+			sub = sub + "00"
+		case 8:
+			sub = sub
+		default:
+			return Color{}, fmt.Errorf("Length of color hex must be 3,4, 6, or 8")
+		}
+		b, err := hex.DecodeString(sub)
+		if err != nil {
+			return Color{}, err
+		}
+		return Color{b[0], b[1], b[2], b[3]}, nil
 	} else {
-		return true
+		col, ok := colornames.Map[str]
+		var err error = nil
+		if !ok {
+			err = fmt.Errorf("Unknown color name: %s", col)
+		}
+		return Color(col), err
 	}
 }
 
+func (p *Parser) ParseColor() Value {
+	return p.ParseWhile(IsColor, func(res string) Value {
+		col, err := StringToColor(res)
+		if err != nil {
+			return p.Error("Could not parse color: %s: %s", res, err)
+		}
+		return col
+	})
+}
+
+type ParserFunc func() Value
+type ParserList []ParserFunc
+
+func (pl ParserList) OneOf() Value {
+	for _, parser := range pl {
+		val := parser()
+		if val != nil {
+			return val
+		}
+	}
+	return nil
+}
+
+func (p Parser) ParseSeparatedList(parseElem, parseSep ParserFunc) Value {
+	list := List{}
+	for elem := parseElem(); elem != nil; elem = parseElem() {
+		list = append(list, elem)
+		sep := parseSep()
+		if sep == nil {
+			break
+		}
+	}
+	return list
+}
+
+type List []Value
+
+func (l List) Eval(gc *GraphicContext) Value {
+	return l
+}
+
+func (p *Parser) ParseElem() Value {
+	l := ParserList{p.ParseVariable, p.ParseNumber}
+	return l.OneOf()
+}
+
+func (p *Parser) ParseList() Value {
+
+}
+
 /*
 
 COMMANDS -> COMMAND COMMANDS | .

+ 14 - 0
svg/style.go

@@ -2,6 +2,8 @@ package svg
 
 import "encoding/xml"
 import "strings"
+import "image/color"
+import "github.com/mazznoer/csscolorparser"
 
 type Style map[string]string
 
@@ -30,3 +32,15 @@ func (s *Style) UnmarshalXMLAttr(attr xml.Attr) error {
 }
 
 var _ xml.UnmarshalerAttr = &Style{}
+
+func (s *Style) ToColor(key string) color.Color {
+	name, ok := s[key]
+	if !ok {
+		return color.RGBA{254, 0, 0, 255}
+	}
+	c, err := csscolorparser.Parse(name)
+	if err != nil {
+		return color.RGBA{253, 0, 0, 255}
+	}
+	return c
+}

+ 70 - 32
svg/svg.go

@@ -2,6 +2,7 @@ package svg
 
 import "encoding/xml"
 import "fmt"
+import "github.com/hajimehoshi/ebiten/v2"
 
 type CoreAttributes struct {
 	Chardata string `xml:",chardata"`
@@ -16,22 +17,17 @@ type Stop struct {
 
 type LinearGradient struct {
 	CoreAttributes
-	Href          string `xml:"href,attr"`
-	X1            string `xml:"x1,attr"`
-	Y1            string `xml:"y1,attr"`
-	X2            string `xml:"x2,attr"`
-	Y2            string `xml:"y2,attr"`
+	Href string `xml:"href,attr"`
+	Linear
 	GradientUnits string `xml:"gradientUnits,attr"`
 	Stop          []Stop `xml:"stop"`
 }
 
 type RadialGradient struct {
 	CoreAttributes
-	Href              string `xml:"href,attr"`
-	Cx                string `xml:"cx,attr"`
-	Cy                string `xml:"cy,attr"`
-	Fx                string `xml:"fx,attr"`
-	Fy                string `xml:"fy,attr"`
+	Href string `xml:"href,attr"`
+	CxCy
+	FxFy
 	R                 string `xml:"r,attr"`
 	GradientTransform string `xml:"gradientTransform,attr"`
 	GradientUnits     string `xml:"gradientUnits,attr"`
@@ -47,41 +43,83 @@ type Defs struct {
 
 type Ellipse struct {
 	CoreAttributes
-	Style string `xml:"style,attr"`
-	Cx    string `xml:"cx,attr"`
-	Cy    string `xml:"cy,attr"`
-	Rx    string `xml:"rx,attr"`
-	Ry    string `xml:"ry,attr"`
+	Style Style `xml:"style,attr"`
+	CxCy
+	Rx string `xml:"rx,attr"`
+	Ry string `xml:"ry,attr"`
+}
+
+type CxCy struct {
+	Cx Length `xml:"cx,attr"`
+	Cy Length `xml:"cy,attr"`
+}
+
+type FxFy struct {
+	Fx Length `xml:"fx,attr"`
+	Fy Length `xml:"fy,attr"`
+}
+
+type XY struct {
+	X Length `xml:"x,attr"`
+	Y Length `xml:"y,attr"`
+}
+
+type X1Y1 struct {
+	X1 Length `xml:"x1,attr"`
+	Y1 Length `xml:"y1,attr"`
+}
+
+type X2Y2 struct {
+	X2 Length `xml:"x2,attr"`
+	Y2 Length `xml:"y2,attr"`
+}
+
+type Linear struct {
+	X1Y1
+	X2Y2
+}
+
+type Tspan struct {
+	CoreAttributes
+	Style Style `xml:"style,attr"`
+	XY
 }
 
 type Text struct {
 	CoreAttributes
 	Space string `xml:"space,attr"`
-	Style string `xml:"style,attr"`
-	X     string `xml:"x,attr"`
-	Y     string `xml:"y,attr"`
-	Tspan struct {
-		CoreAttributes
-		Style string `xml:"style,attr"`
-		X     string `xml:"x,attr"`
-		Y     string `xml:"y,attr"`
-	} `xml:"tspan"`
+	Style Style  `xml:"style,attr"`
+	XY
+	Tspan Tspan `xml:"tspan"`
 }
 
 type Rect struct {
-	Text   string `xml:",chardata"`
-	Style  string `xml:"style,attr"`
-	ID     string `xml:"id,attr"`
+	CoreAttributes
+	Style Style `xml:"style,attr"`
+	XY
 	Width  string `xml:"width,attr"`
 	Height string `xml:"height,attr"`
-	X      string `xml:"x,attr"`
-	Y      string `xml:"y,attr"`
+}
+
+func (r Rect) Draw(target *ebiten.Image, options *DrawOptions) {
+	p := vector.Path{}
+	fo := vector.FillOptions{Color: r.ToColor("color")}
+	p.MoveTo(r.X, r.Y).LineTo(r.X, r.Y+r.Width).
+		LineTo(r.X+r.Height, r.Y+r.Width).LineTo(r.X+r.Height, r.Y).
+		p.LineTo(r.X, r.Y).Fill(target, fo)
+}
+
+type DrawOptions struct {
+	X float32
+	Y float32
+}
+
+type Drawer interface {
+	Draw(target *ebiten.Image, options *DrawOptions)
 }
 
 type Drawable struct {
-	*Rect
-	*Ellipse
-	*Text
+	Drawer
 }
 
 func (d *Drawable) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {

+ 62 - 27
testdata/yellow_circle.svg

@@ -7,9 +7,32 @@
    viewBox="0 0 210 297"
    version="1.1"
    id="svg5"
+   sodipodi:docname="yellow_circle.svg"
+   inkscape:version="1.1 (ce6663b3b7, 2021-05-25)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
    xmlns:xlink="http://www.w3.org/1999/xlink"
    xmlns="http://www.w3.org/2000/svg"
    xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview18"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:document-units="mm"
+     showgrid="false"
+     inkscape:zoom="0.49620511"
+     inkscape:cx="396.0056"
+     inkscape:cy="561.25984"
+     inkscape:window-width="1366"
+     inkscape:window-height="727"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="layer1" />
   <defs
      id="defs2">
     <linearGradient
@@ -41,7 +64,8 @@
        y1="92.165146"
        x2="177.97886"
        y2="92.165146"
-       gradientUnits="userSpaceOnUse" />
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="matrix(1.0136756,0,0,0.99422,-0.41283437,-1.6324317)" />
     <radialGradient
        xlink:href="#linearGradient5432"
        id="radialGradient5434"
@@ -50,41 +74,52 @@
        fx="70.214874"
        fy="52.364155"
        r="39.470493"
-       gradientTransform="matrix(1,0,0,0.95443934,0,2.3857454)"
+       gradientTransform="matrix(0.88673835,0,0,0.76006144,2.7377823,5.200025)"
        gradientUnits="userSpaceOnUse" />
   </defs>
   <g
      id="layer1">
     <ellipse
-       style="fill:url(#radialGradient5434);stroke-width:0.264583;fill-opacity:1"
+       style="fill:url(#radialGradient5434);fill-opacity:1;stroke-width:0.222336"
        id="path49"
-       cx="70.214874"
-       cy="52.364155"
-       rx="39.470493"
-       ry="37.672192" />
-    <text
-       xml:space="preserve"
-       style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583"
-       x="59.172764"
-       y="104.7185"
-       id="text1574"><tspan
-         style="stroke-width:0.264583"
-         x="59.172764"
-         y="104.7185"
-         id="tspan1576">hello</tspan></text>
+       cx="65"
+       cy="45"
+       rx="35"
+       ry="30" />
     <rect
-       style="fill:url(#linearGradient5346);stroke-width:0.264583;fill-opacity:1"
+       style="fill:url(#linearGradient5346);fill-opacity:1;stroke-width:0.265615"
        id="rect4440"
-       width="88.785797"
-       height="80.465088"
-       x="89.193062"
-       y="51.932602" />
+       width="90"
+       height="80"
+       x="90"
+       y="50" />
+    <text
+       xml:space="preserve"
+       style="font-style:normal;font-weight:normal;font-size:12.915px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.322874"
+       x="67.689934"
+       y="96.942001"
+       id="text1574"
+       transform="scale(0.87129679,1.1477145)"><tspan
+         sodipodi:role="line"
+         id="tspan3219"
+         x="67.689934"
+         y="96.942001"
+         style="stroke-width:0.322874"><tspan
+           style="stroke-width:0.322874"
+           x="67.689934"
+           y="96.942001"
+           id="tspan1576">hello 'world'</tspan></tspan><tspan
+         sodipodi:role="line"
+         id="tspan3221"
+         x="67.689934"
+         y="113.08575"
+         style="stroke-width:0.322874">next line</tspan></text>
     <rect
-       style="fill:#0000ff;stroke-width:0.264583"
+       style="fill:#0000ff;stroke-width:0.259975"
        id="rect5458"
-       width="36.873154"
-       height="22.471991"
-       x="31.896166"
-       y="133.12292" />
+       width="40"
+       height="20"
+       x="30"
+       y="140" />
   </g>
 </svg>