Former/svg_parse.go

879 lines
19 KiB
Go

package main
import (
"encoding/xml"
"fmt"
"math"
"os"
"regexp"
"strconv"
"strings"
"unicode"
)
type SVGDocument struct {
Width float64
Height float64
ViewBox [4]float64
Elements []SVGElement
Groups []SVGGroup
Warnings []string
RawSVG []byte
}
type SVGElement struct {
Type string
PathData string
Segments []PathSegment
Transform [6]float64
Fill string
Stroke string
StrokeW float64
GroupID int
BBox Bounds
}
type PathSegment struct {
Command byte
Args []float64
}
type SVGGroup struct {
ID string
Label string
Transform [6]float64
Children []int
Visible bool
}
func ParseSVG(path string) (*SVGDocument, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read SVG: %v", err)
}
doc := &SVGDocument{
RawSVG: data,
}
decoder := xml.NewDecoder(strings.NewReader(string(data)))
var groupStack []int
currentGroupID := -1
for {
tok, err := decoder.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "svg":
doc.parseSVGRoot(t)
case "g":
g := parseGroupAttrs(t)
gid := len(doc.Groups)
doc.Groups = append(doc.Groups, g)
if currentGroupID >= 0 {
doc.Groups[currentGroupID].Children = append(doc.Groups[currentGroupID].Children, gid)
}
groupStack = append(groupStack, currentGroupID)
currentGroupID = gid
case "path":
el := doc.parsePathElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "rect":
el := doc.parseRectElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "circle":
el := doc.parseCircleElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "ellipse":
el := doc.parseEllipseElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "line":
el := doc.parseLineElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "polyline":
el := doc.parsePolyElement(t, currentGroupID, "polyline")
doc.Elements = append(doc.Elements, el)
case "polygon":
el := doc.parsePolyElement(t, currentGroupID, "polygon")
doc.Elements = append(doc.Elements, el)
case "text":
doc.Warnings = appendUnique(doc.Warnings, "Convert text to paths before importing")
decoder.Skip()
case "image":
doc.Warnings = appendUnique(doc.Warnings, "Embedded raster images (<image>) are not supported")
decoder.Skip()
}
case xml.EndElement:
if t.Name.Local == "g" && len(groupStack) > 0 {
currentGroupID = groupStack[len(groupStack)-1]
groupStack = groupStack[:len(groupStack)-1]
}
}
}
for i := range doc.Elements {
doc.Elements[i].BBox = computeElementBBox(&doc.Elements[i])
}
return doc, nil
}
// identityTransform returns [a,b,c,d,e,f] for the identity matrix
func identityTransform() [6]float64 {
return [6]float64{1, 0, 0, 1, 0, 0}
}
func (doc *SVGDocument) parseSVGRoot(el xml.StartElement) {
for _, a := range el.Attr {
switch a.Name.Local {
case "width":
doc.Width = parseLengthMM(a.Value)
case "height":
doc.Height = parseLengthMM(a.Value)
case "viewBox":
parts := splitFloats(a.Value)
if len(parts) >= 4 {
doc.ViewBox = [4]float64{parts[0], parts[1], parts[2], parts[3]}
}
case "version":
if a.Value != "" && a.Value != "1.1" {
doc.Warnings = appendUnique(doc.Warnings, "SVG version is "+a.Value+"; for best results re-export as plain SVG 1.1 from Inkscape")
}
}
}
if doc.Width == 0 && doc.ViewBox[2] > 0 {
doc.Width = doc.ViewBox[2] * (25.4 / 96.0)
}
if doc.Height == 0 && doc.ViewBox[3] > 0 {
doc.Height = doc.ViewBox[3] * (25.4 / 96.0)
}
}
func parseGroupAttrs(el xml.StartElement) SVGGroup {
g := SVGGroup{
Transform: identityTransform(),
Visible: true,
}
for _, a := range el.Attr {
switch {
case a.Name.Local == "id":
g.ID = a.Value
case a.Name.Local == "transform":
g.Transform = parseTransform(a.Value)
case a.Name.Local == "label" && a.Name.Space == "http://www.inkscape.org/namespaces/inkscape":
g.Label = a.Value
case a.Name.Local == "style":
if strings.Contains(a.Value, "display:none") || strings.Contains(a.Value, "display: none") {
g.Visible = false
}
case a.Name.Local == "display":
if a.Value == "none" {
g.Visible = false
}
}
}
return g
}
func (doc *SVGDocument) baseElement(el xml.StartElement, groupID int) SVGElement {
e := SVGElement{
Transform: identityTransform(),
GroupID: groupID,
}
for _, a := range el.Attr {
switch a.Name.Local {
case "transform":
e.Transform = parseTransform(a.Value)
case "fill":
e.Fill = a.Value
case "stroke":
e.Stroke = a.Value
case "stroke-width":
e.StrokeW, _ = strconv.ParseFloat(a.Value, 64)
case "style":
e.parseStyleAttr(a.Value)
}
}
return e
}
func (e *SVGElement) parseStyleAttr(style string) {
for _, part := range strings.Split(style, ";") {
kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
if len(kv) != 2 {
continue
}
k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
switch k {
case "fill":
e.Fill = v
case "stroke":
e.Stroke = v
case "stroke-width":
e.StrokeW, _ = strconv.ParseFloat(v, 64)
}
}
}
func (doc *SVGDocument) parsePathElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "path"
for _, a := range el.Attr {
if a.Name.Local == "d" {
e.PathData = a.Value
e.Segments = parsePath(a.Value)
}
}
return e
}
func (doc *SVGDocument) parseRectElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "rect"
var x, y, w, h, rx, ry float64
for _, a := range el.Attr {
switch a.Name.Local {
case "x":
x, _ = strconv.ParseFloat(a.Value, 64)
case "y":
y, _ = strconv.ParseFloat(a.Value, 64)
case "width":
w, _ = strconv.ParseFloat(a.Value, 64)
case "height":
h, _ = strconv.ParseFloat(a.Value, 64)
case "rx":
rx, _ = strconv.ParseFloat(a.Value, 64)
case "ry":
ry, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = rectToPath(x, y, w, h, rx, ry)
return e
}
func (doc *SVGDocument) parseCircleElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "circle"
var cx, cy, r float64
for _, a := range el.Attr {
switch a.Name.Local {
case "cx":
cx, _ = strconv.ParseFloat(a.Value, 64)
case "cy":
cy, _ = strconv.ParseFloat(a.Value, 64)
case "r":
r, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = circleToPath(cx, cy, r)
return e
}
func (doc *SVGDocument) parseEllipseElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "ellipse"
var cx, cy, rx, ry float64
for _, a := range el.Attr {
switch a.Name.Local {
case "cx":
cx, _ = strconv.ParseFloat(a.Value, 64)
case "cy":
cy, _ = strconv.ParseFloat(a.Value, 64)
case "rx":
rx, _ = strconv.ParseFloat(a.Value, 64)
case "ry":
ry, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = ellipseToPath(cx, cy, rx, ry)
return e
}
func (doc *SVGDocument) parseLineElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "line"
var x1, y1, x2, y2 float64
for _, a := range el.Attr {
switch a.Name.Local {
case "x1":
x1, _ = strconv.ParseFloat(a.Value, 64)
case "y1":
y1, _ = strconv.ParseFloat(a.Value, 64)
case "x2":
x2, _ = strconv.ParseFloat(a.Value, 64)
case "y2":
y2, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = lineToPath(x1, y1, x2, y2)
return e
}
func (doc *SVGDocument) parsePolyElement(el xml.StartElement, groupID int, typ string) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = typ
for _, a := range el.Attr {
if a.Name.Local == "points" {
pts := splitFloats(a.Value)
if typ == "polygon" {
e.Segments = polygonToPath(pts)
} else {
e.Segments = polylineToPath(pts)
}
}
}
return e
}
// Shape-to-path converters
func rectToPath(x, y, w, h, rx, ry float64) []PathSegment {
if rx == 0 && ry == 0 {
return []PathSegment{
{Command: 'M', Args: []float64{x, y}},
{Command: 'L', Args: []float64{x + w, y}},
{Command: 'L', Args: []float64{x + w, y + h}},
{Command: 'L', Args: []float64{x, y + h}},
{Command: 'Z'},
}
}
if rx == 0 {
rx = ry
}
if ry == 0 {
ry = rx
}
if rx > w/2 {
rx = w / 2
}
if ry > h/2 {
ry = h / 2
}
return []PathSegment{
{Command: 'M', Args: []float64{x + rx, y}},
{Command: 'L', Args: []float64{x + w - rx, y}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + w, y + ry}},
{Command: 'L', Args: []float64{x + w, y + h - ry}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + w - rx, y + h}},
{Command: 'L', Args: []float64{x + rx, y + h}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x, y + h - ry}},
{Command: 'L', Args: []float64{x, y + ry}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + rx, y}},
{Command: 'Z'},
}
}
func circleToPath(cx, cy, r float64) []PathSegment {
return ellipseToPath(cx, cy, r, r)
}
func ellipseToPath(cx, cy, rx, ry float64) []PathSegment {
return []PathSegment{
{Command: 'M', Args: []float64{cx - rx, cy}},
{Command: 'A', Args: []float64{rx, ry, 0, 1, 0, cx + rx, cy}},
{Command: 'A', Args: []float64{rx, ry, 0, 1, 0, cx - rx, cy}},
{Command: 'Z'},
}
}
func lineToPath(x1, y1, x2, y2 float64) []PathSegment {
return []PathSegment{
{Command: 'M', Args: []float64{x1, y1}},
{Command: 'L', Args: []float64{x2, y2}},
}
}
func polylineToPath(coords []float64) []PathSegment {
if len(coords) < 4 {
return nil
}
segs := []PathSegment{
{Command: 'M', Args: []float64{coords[0], coords[1]}},
}
for i := 2; i+1 < len(coords); i += 2 {
segs = append(segs, PathSegment{Command: 'L', Args: []float64{coords[i], coords[i+1]}})
}
return segs
}
func polygonToPath(coords []float64) []PathSegment {
segs := polylineToPath(coords)
if len(segs) > 0 {
segs = append(segs, PathSegment{Command: 'Z'})
}
return segs
}
// Transform parsing
var transformFuncRe = regexp.MustCompile(`(\w+)\s*\(([^)]*)\)`)
func parseTransform(attr string) [6]float64 {
result := identityTransform()
matches := transformFuncRe.FindAllStringSubmatch(attr, -1)
for _, m := range matches {
fn := m[1]
args := splitFloats(m[2])
var t [6]float64
switch fn {
case "matrix":
if len(args) >= 6 {
t = [6]float64{args[0], args[1], args[2], args[3], args[4], args[5]}
} else {
continue
}
case "translate":
tx := 0.0
ty := 0.0
if len(args) >= 1 {
tx = args[0]
}
if len(args) >= 2 {
ty = args[1]
}
t = [6]float64{1, 0, 0, 1, tx, ty}
case "scale":
sx := 1.0
sy := 1.0
if len(args) >= 1 {
sx = args[0]
}
if len(args) >= 2 {
sy = args[1]
} else {
sy = sx
}
t = [6]float64{sx, 0, 0, sy, 0, 0}
case "rotate":
if len(args) < 1 {
continue
}
angle := args[0] * math.Pi / 180.0
c, s := math.Cos(angle), math.Sin(angle)
if len(args) >= 3 {
cx, cy := args[1], args[2]
t = [6]float64{c, s, -s, c, cx - c*cx + s*cy, cy - s*cx - c*cy}
} else {
t = [6]float64{c, s, -s, c, 0, 0}
}
case "skewX":
if len(args) < 1 {
continue
}
t = [6]float64{1, 0, math.Tan(args[0] * math.Pi / 180.0), 1, 0, 0}
case "skewY":
if len(args) < 1 {
continue
}
t = [6]float64{1, math.Tan(args[0] * math.Pi / 180.0), 0, 1, 0, 0}
default:
continue
}
result = composeTransforms(result, t)
}
return result
}
func composeTransforms(a, b [6]float64) [6]float64 {
return [6]float64{
a[0]*b[0] + a[2]*b[1],
a[1]*b[0] + a[3]*b[1],
a[0]*b[2] + a[2]*b[3],
a[1]*b[2] + a[3]*b[3],
a[0]*b[4] + a[2]*b[5] + a[4],
a[1]*b[4] + a[3]*b[5] + a[5],
}
}
// Path d attribute parser
func parsePath(d string) []PathSegment {
var segments []PathSegment
tokens := tokenizePath(d)
if len(tokens) == 0 {
return nil
}
var curX, curY float64
var startX, startY float64
var lastCmd byte
i := 0
for i < len(tokens) {
tok := tokens[i]
cmd := byte(0)
if len(tok) == 1 && isPathCommand(tok[0]) {
cmd = tok[0]
i++
} else if lastCmd != 0 {
cmd = lastCmd
if cmd == 'M' {
cmd = 'L'
} else if cmd == 'm' {
cmd = 'l'
}
} else {
break
}
rel := cmd >= 'a' && cmd <= 'z'
upper := cmd
if rel {
upper = cmd - 32
}
switch upper {
case 'M':
if i+1 >= len(tokens) {
break
}
x, y := parseF(tokens[i]), parseF(tokens[i+1])
i += 2
if rel {
x += curX
y += curY
}
curX, curY = x, y
startX, startY = x, y
segments = append(segments, PathSegment{Command: 'M', Args: []float64{x, y}})
case 'L':
if i+1 >= len(tokens) {
break
}
x, y := parseF(tokens[i]), parseF(tokens[i+1])
i += 2
if rel {
x += curX
y += curY
}
curX, curY = x, y
segments = append(segments, PathSegment{Command: 'L', Args: []float64{x, y}})
case 'H':
if i >= len(tokens) {
break
}
x := parseF(tokens[i])
i++
if rel {
x += curX
}
curX = x
segments = append(segments, PathSegment{Command: 'L', Args: []float64{curX, curY}})
case 'V':
if i >= len(tokens) {
break
}
y := parseF(tokens[i])
i++
if rel {
y += curY
}
curY = y
segments = append(segments, PathSegment{Command: 'L', Args: []float64{curX, curY}})
case 'C':
if i+5 >= len(tokens) {
break
}
args := make([]float64, 6)
for j := 0; j < 6; j++ {
args[j] = parseF(tokens[i+j])
}
i += 6
if rel {
for j := 0; j < 6; j += 2 {
args[j] += curX
args[j+1] += curY
}
}
curX, curY = args[4], args[5]
segments = append(segments, PathSegment{Command: 'C', Args: args})
case 'S':
if i+3 >= len(tokens) {
break
}
args := make([]float64, 4)
for j := 0; j < 4; j++ {
args[j] = parseF(tokens[i+j])
}
i += 4
if rel {
for j := 0; j < 4; j += 2 {
args[j] += curX
args[j+1] += curY
}
}
curX, curY = args[2], args[3]
segments = append(segments, PathSegment{Command: 'S', Args: args})
case 'Q':
if i+3 >= len(tokens) {
break
}
args := make([]float64, 4)
for j := 0; j < 4; j++ {
args[j] = parseF(tokens[i+j])
}
i += 4
if rel {
for j := 0; j < 4; j += 2 {
args[j] += curX
args[j+1] += curY
}
}
curX, curY = args[2], args[3]
segments = append(segments, PathSegment{Command: 'Q', Args: args})
case 'T':
if i+1 >= len(tokens) {
break
}
x, y := parseF(tokens[i]), parseF(tokens[i+1])
i += 2
if rel {
x += curX
y += curY
}
curX, curY = x, y
segments = append(segments, PathSegment{Command: 'T', Args: []float64{x, y}})
case 'A':
if i+6 >= len(tokens) {
break
}
rx := parseF(tokens[i])
ry := parseF(tokens[i+1])
rot := parseF(tokens[i+2])
largeArc := parseF(tokens[i+3])
sweep := parseF(tokens[i+4])
x := parseF(tokens[i+5])
y := parseF(tokens[i+6])
i += 7
if rel {
x += curX
y += curY
}
curX, curY = x, y
segments = append(segments, PathSegment{Command: 'A', Args: []float64{rx, ry, rot, largeArc, sweep, x, y}})
case 'Z':
curX, curY = startX, startY
segments = append(segments, PathSegment{Command: 'Z'})
default:
i++
}
lastCmd = cmd
}
return segments
}
func isPathCommand(c byte) bool {
return strings.ContainsRune("MmLlHhVvCcSsQqTtAaZz", rune(c))
}
func tokenizePath(d string) []string {
var tokens []string
var buf strings.Builder
flush := func() {
s := buf.String()
if s != "" {
tokens = append(tokens, s)
buf.Reset()
}
}
for i := 0; i < len(d); i++ {
c := d[i]
if isPathCommand(c) {
flush()
tokens = append(tokens, string(c))
} else if c == ',' || c == ' ' || c == '\t' || c == '\n' || c == '\r' {
flush()
} else if c == '-' && buf.Len() > 0 {
// Negative sign starts new number unless after 'e'/'E' (exponent)
s := buf.String()
lastChar := s[len(s)-1]
if lastChar != 'e' && lastChar != 'E' {
flush()
}
buf.WriteByte(c)
} else if c == '.' && strings.Contains(buf.String(), ".") {
flush()
buf.WriteByte(c)
} else {
buf.WriteByte(c)
}
}
flush()
return tokens
}
func parseF(s string) float64 {
v, _ := strconv.ParseFloat(s, 64)
return v
}
// Unit conversion
func parseLengthMM(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
unit := ""
numStr := s
for _, u := range []string{"mm", "cm", "in", "pt", "px"} {
if strings.HasSuffix(s, u) {
unit = u
numStr = strings.TrimSpace(s[:len(s)-len(u)])
break
}
}
// Strip non-numeric trailing chars
numStr = strings.TrimRightFunc(numStr, func(r rune) bool {
return !unicode.IsDigit(r) && r != '.' && r != '-' && r != '+'
})
v, _ := strconv.ParseFloat(numStr, 64)
switch unit {
case "mm":
return v
case "cm":
return v * 10.0
case "in":
return v * 25.4
case "pt":
return v * (25.4 / 72.0)
case "px", "":
return v * (25.4 / 96.0)
}
return v * (25.4 / 96.0)
}
// Bounding box computation (approximate, from segments)
func computeElementBBox(el *SVGElement) Bounds {
b := Bounds{MinX: math.MaxFloat64, MinY: math.MaxFloat64, MaxX: -math.MaxFloat64, MaxY: -math.MaxFloat64}
expandPt := func(x, y float64) {
tx := el.Transform[0]*x + el.Transform[2]*y + el.Transform[4]
ty := el.Transform[1]*x + el.Transform[3]*y + el.Transform[5]
if tx < b.MinX {
b.MinX = tx
}
if tx > b.MaxX {
b.MaxX = tx
}
if ty < b.MinY {
b.MinY = ty
}
if ty > b.MaxY {
b.MaxY = ty
}
}
for _, seg := range el.Segments {
switch seg.Command {
case 'M', 'L', 'T':
if len(seg.Args) >= 2 {
expandPt(seg.Args[0], seg.Args[1])
}
case 'C':
if len(seg.Args) >= 6 {
expandPt(seg.Args[0], seg.Args[1])
expandPt(seg.Args[2], seg.Args[3])
expandPt(seg.Args[4], seg.Args[5])
}
case 'S', 'Q':
if len(seg.Args) >= 4 {
expandPt(seg.Args[0], seg.Args[1])
expandPt(seg.Args[2], seg.Args[3])
}
case 'A':
if len(seg.Args) >= 7 {
expandPt(seg.Args[5], seg.Args[6])
expandPt(seg.Args[5]-seg.Args[0], seg.Args[6]-seg.Args[1])
expandPt(seg.Args[5]+seg.Args[0], seg.Args[6]+seg.Args[1])
}
}
}
if b.MinX == math.MaxFloat64 {
return Bounds{}
}
return b
}
// Helpers
func splitFloats(s string) []float64 {
s = strings.Map(func(r rune) rune {
if r == ',' {
return ' '
}
return r
}, s)
var result []float64
for _, part := range strings.Fields(s) {
v, err := strconv.ParseFloat(part, 64)
if err == nil {
result = append(result, v)
}
}
return result
}
func appendUnique(warnings []string, msg string) []string {
for _, w := range warnings {
if w == msg {
return warnings
}
}
return append(warnings, msg)
}
func (doc *SVGDocument) LayerCount() int {
count := 0
for _, g := range doc.Groups {
if g.Label != "" {
count++
}
}
return count
}
func (doc *SVGDocument) VisibleLayerNames() []string {
var names []string
for _, g := range doc.Groups {
if g.Label != "" && g.Visible {
names = append(names, g.Label)
}
}
return names
}