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 () 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 }