package svg import ( "fmt" "strconv" "strings" ) // flattenPath turns a path-data subset (M L H V C Q Z, absolute and // relative) into one or more polylines, used for validation, preview // rasterization and measurements. Curves become 16 line segments. func flattenPath(d string) ([][][2]float64, error) { tokens, err := pathTokens(d) if err != nil { return nil, err } var subpaths [][][2]float64 var cur [][2]float64 var x, y, startX, startY float64 i := 0 cmd := "" need := func(n int) ([]float64, error) { if i+n > len(tokens) { return nil, fmt.Errorf("command %s needs %d numbers", cmd, n) } out := make([]float64, n) for j := 0; j < n; j++ { v, err := strconv.ParseFloat(tokens[i+j], 64) if err != nil { return nil, fmt.Errorf("command %s: %q is not a number", cmd, tokens[i+j]) } out[j] = v } i += n return out, nil } flush := func() { if len(cur) > 1 { subpaths = append(subpaths, cur) } cur = nil } for i < len(tokens) { t := tokens[i] if len(t) == 1 && strings.ContainsAny(t, "MLHVCQZmlhvcqz") { cmd = t i++ if cmd == "Z" || cmd == "z" { if len(cur) > 0 { cur = append(cur, [2]float64{startX, startY}) x, y = startX, startY } flush() continue } } else if cmd == "" { return nil, fmt.Errorf("path must start with M/m, got %q", t) } // repeated coordinate groups reuse the current command rel := cmd >= "a" // lowercase = relative switch strings.ToUpper(cmd) { case "M": n, err := need(2) if err != nil { return nil, err } if rel { n[0] += x n[1] += y } flush() x, y = n[0], n[1] startX, startY = x, y cur = [][2]float64{{x, y}} cmd = map[bool]string{true: "l", false: "L"}[rel] // subsequent pairs are implicit lineto case "L": n, err := need(2) if err != nil { return nil, err } if rel { n[0] += x n[1] += y } x, y = n[0], n[1] cur = append(cur, [2]float64{x, y}) case "H": n, err := need(1) if err != nil { return nil, err } if rel { n[0] += x } x = n[0] cur = append(cur, [2]float64{x, y}) case "V": n, err := need(1) if err != nil { return nil, err } if rel { n[0] += y } y = n[0] cur = append(cur, [2]float64{x, y}) case "Q": n, err := need(4) if err != nil { return nil, err } if rel { n[0] += x n[1] += y n[2] += x n[3] += y } for s := 1; s <= 16; s++ { t := float64(s) / 16 u := 1 - t px := u*u*x + 2*u*t*n[0] + t*t*n[2] py := u*u*y + 2*u*t*n[1] + t*t*n[3] cur = append(cur, [2]float64{px, py}) } x, y = n[2], n[3] case "C": n, err := need(6) if err != nil { return nil, err } if rel { n[0] += x n[1] += y n[2] += x n[3] += y n[4] += x n[5] += y } for s := 1; s <= 16; s++ { t := float64(s) / 16 u := 1 - t px := u*u*u*x + 3*u*u*t*n[0] + 3*u*t*t*n[2] + t*t*t*n[4] py := u*u*u*y + 3*u*u*t*n[1] + 3*u*t*t*n[3] + t*t*t*n[5] cur = append(cur, [2]float64{px, py}) } x, y = n[4], n[5] default: return nil, fmt.Errorf("unsupported path command %q (supported: M L H V C Q Z)", cmd) } } flush() if len(subpaths) == 0 { return nil, fmt.Errorf("path draws nothing") } return subpaths, nil } // pathTokens splits path data into command letters and numbers. func pathTokens(d string) ([]string, error) { var out []string var cur strings.Builder flush := func() { if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() } } for _, r := range d { switch { case r == ' ' || r == '\t' || r == ',': flush() case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'): flush() out = append(out, string(r)) case (r >= '0' && r <= '9') || r == '.' || r == 'e' || r == 'E': cur.WriteRune(r) case r == '-' || r == '+': // sign starts a new number unless it follows an exponent s := cur.String() if cur.Len() > 0 && !strings.HasSuffix(s, "e") && !strings.HasSuffix(s, "E") { flush() } cur.WriteRune(r) default: return nil, fmt.Errorf("unexpected character %q in path data", r) } } flush() if len(out) == 0 { return nil, fmt.Errorf("empty path data") } return out, nil }