// Package d2svgimport imports the bounded, network-free SVG subset used by D2 // assets and MathJax into d2scene.
package d2svgimport import ( ) // PathLimits are caller-selected hard limits for one SVG path data string. // MaxCommands counts parsed source command groups, including groups such as an // identical-endpoint arc that SVG defines to emit no segment. Both values must // be positive. type PathLimits struct { MaxBytes int MaxCommands int } // ParsePath converts the complete common SVG path grammar to absolute typed // d2scene commands. source must already be safe to include in an error (asset // resolvers should pass their redacted display name, never a credentialed URL). func ( context.Context, , string, PathLimits) ([]d2scene.PathCommand, error) { , , := parsePathWithCount(, , , ) return , } // parsePathWithCount also returns the number of parsed source command groups. // ImportNode uses that count for its document-wide source limit; it must not // infer source work from the smaller emitted command slice. func parsePathWithCount( context.Context, , string, PathLimits) ([]d2scene.PathCommand, int, error) { if == nil { = context.Background() } if := .Err(); != nil { return nil, 0, } if .MaxBytes <= 0 || .MaxCommands <= 0 { return nil, 0, fmt.Errorf("d2svgimport: path limits must be positive") } if len() > .MaxBytes { return nil, 0, fmt.Errorf("d2svgimport: %s path data is %d bytes, exceeding limit %d", displaySource(), len(), .MaxBytes) } := pathParser{ ctx: , source: displaySource(), data: , maxCommands: .MaxCommands, } , := .parse() if != nil { return nil, 0, } return , .parsedCommands, nil } type pathParser struct { ctx context.Context source string data string offset int maxCommands int commands []d2scene.PathCommand // parsedCommands counts source command groups rather than emitted scene // commands. Degenerate arcs may intentionally emit nothing. parsedCommands int // afterExplicitCommand makes the first numeric argument reject a leading // comma. Commas remain valid between arguments and implicit groups. afterExplicitCommand bool current d2scene.Point subpathStart d2scene.Point haveCurrent bool previous byte lastCubic d2scene.Point lastQuad d2scene.Point } func ( *pathParser) () ([]d2scene.PathCommand, error) { var byte for { if := .skipWhitespace(); != nil { return nil, } if .offset == len(.data) { return append([]d2scene.PathCommand(nil), .commands...), nil } := false if isCommand(.data[.offset]) { = .data[.offset] .offset++ = true if == 'Z' || == 'z' { if !.haveCurrent { return nil, .errorf("close command appears before a move") } if := .chargeCommand(); != nil { return nil, } if := .append(d2scene.ClosePath()); != nil { return nil, } .current = .subpathStart .previous = = 0 continue } } else if == 0 { return nil, .errorf("expected a path command") } := == 'M' || == 'm' if := .chargeCommand(); != nil { return nil, } .afterExplicitCommand = if := .parseGroup(); != nil { return nil, } if { if == 'M' { = 'L' } else { = 'l' } } } } func ( *pathParser) ( byte) error { if := .ctx.Err(); != nil { return } := >= 'a' && <= 'z' := if { -= 'a' - 'A' } if != 'M' && !.haveCurrent { return .errorf("%c command appears before a move", ) } := func(, float64) d2scene.Point { if { += .current.X += .current.Y } return d2scene.Point{X: , Y: } } := func() { .lastCubic = d2scene.Point{} .lastQuad = d2scene.Point{} } switch { case 'M': , , := .numberPair() if != nil { return } := (, ) if := .append(d2scene.MoveTo(.X, .Y)); != nil { return } .current, .subpathStart, .haveCurrent = , , true () case 'L': , , := .numberPair() if != nil { return } := (, ) if := .append(d2scene.LineTo(.X, .Y)); != nil { return } .current = () case 'H': , := .number() if != nil { return } if { += .current.X } if := .append(d2scene.LineTo(, .current.Y)); != nil { return } .current.X = () case 'V': , := .number() if != nil { return } if { += .current.Y } if := .append(d2scene.LineTo(.current.X, )); != nil { return } .current.Y = () case 'C': , := .numbers(6) if != nil { return } := ([0], [1]) := ([2], [3]) := ([4], [5]) if := .append(d2scene.CubicTo(.X, .Y, .X, .Y, .X, .Y)); != nil { return } .current, .lastCubic, .lastQuad = , , d2scene.Point{} case 'S': , := .numbers(4) if != nil { return } := .current if previousUpper(.previous) == 'C' || previousUpper(.previous) == 'S' { = reflectPoint(.lastCubic, .current) } := ([0], [1]) := ([2], [3]) if := .append(d2scene.CubicTo(.X, .Y, .X, .Y, .X, .Y)); != nil { return } .current, .lastCubic, .lastQuad = , , d2scene.Point{} case 'Q': , := .numbers(4) if != nil { return } := ([0], [1]) := ([2], [3]) if := .append(d2scene.QuadraticTo(.X, .Y, .X, .Y)); != nil { return } .current, .lastQuad, .lastCubic = , , d2scene.Point{} case 'T': , , := .numberPair() if != nil { return } := .current if previousUpper(.previous) == 'Q' || previousUpper(.previous) == 'T' { = reflectPoint(.lastQuad, .current) } := (, ) if := .append(d2scene.QuadraticTo(.X, .Y, .X, .Y)); != nil { return } .current, .lastQuad, .lastCubic = , , d2scene.Point{} case 'A': , := .number() if != nil { return } , := .number() if != nil { return } , := .number() if != nil { return } , := .flag() if != nil { return } , := .flag() if != nil { return } , , := .numberPair() if != nil { return } if < 0 || < 0 { return .errorf("arc radii must be non-negative") } := (, ) if != .current { if == 0 || == 0 { if := .append(d2scene.LineTo(.X, .Y)); != nil { return } } else if := .append(d2scene.ArcTo(, , math.Remainder(, 360)/180*math.Pi, , , .X, .Y)); != nil { return } } .current = () default: return .errorf("unsupported path command %q", ) } .previous = return nil } func ( *pathParser) () (float64, float64, error) { , := .numbers(2) if != nil { return 0, 0, } return [0], [1], nil } func ( *pathParser) ( int) ([]float64, error) { := make([]float64, ) for := range { , := .number() if != nil { return nil, } [] = } return , nil } func ( *pathParser) () (float64, error) { if .afterExplicitCommand { .afterExplicitCommand = false if := .skipWhitespace(); != nil { return 0, } if .offset < len(.data) && .data[.offset] == ',' { return 0, .errorf("comma is not allowed before a command's first argument") } } else { if := .separator(); != nil { return 0, } } := .offset if .offset < len(.data) && (.data[.offset] == '+' || .data[.offset] == '-') { .offset++ } := 0 for .offset < len(.data) && isDigit(.data[.offset]) { .offset++ ++ if .offset&4095 == 0 { if := .ctx.Err(); != nil { return 0, } } } if .offset < len(.data) && .data[.offset] == '.' { .offset++ for .offset < len(.data) && isDigit(.data[.offset]) { .offset++ ++ if .offset&4095 == 0 { if := .ctx.Err(); != nil { return 0, } } } } if == 0 { return 0, .errorAt(, "expected a finite number") } if .offset < len(.data) && (.data[.offset] == 'e' || .data[.offset] == 'E') { := .offset .offset++ if .offset < len(.data) && (.data[.offset] == '+' || .data[.offset] == '-') { .offset++ } := 0 for .offset < len(.data) && isDigit(.data[.offset]) { .offset++ ++ if .offset&4095 == 0 { if := .ctx.Err(); != nil { return 0, } } } if == 0 { return 0, .errorAt(, "number exponent has no digits") } } if := .ctx.Err(); != nil { return 0, } , := strconv.ParseFloat(.data[:.offset], 64) if != nil || math.IsNaN() || math.IsInf(, 0) { return 0, .errorAt(, "expected a finite number") } return , nil } func ( *pathParser) () (bool, error) { if := .separator(); != nil { return false, } if .offset >= len(.data) || (.data[.offset] != '0' && .data[.offset] != '1') { return false, .errorf("arc flag must be 0 or 1") } := .data[.offset] == '1' .offset++ return , nil } func ( *pathParser) () error { if := .skipWhitespace(); != nil { return } if .offset < len(.data) && .data[.offset] == ',' { .offset++ if := .skipWhitespace(); != nil { return } if .offset < len(.data) && .data[.offset] == ',' { return .errorf("repeated comma in path data") } } return nil } func ( *pathParser) () error { for .offset < len(.data) && isWhitespace(.data[.offset]) { .offset++ if .offset&4095 == 0 { if := .ctx.Err(); != nil { return } } } return .ctx.Err() } func ( *pathParser) ( d2scene.PathCommand) error { := func( d2scene.Point) bool { return !math.IsNaN(.X) && !math.IsInf(.X, 0) && !math.IsNaN(.Y) && !math.IsInf(.Y, 0) } := true switch .Kind { case d2scene.MoveCommand, d2scene.LineCommand: = (.P1) case d2scene.QuadraticCommand: = (.P1) && (.P2) case d2scene.CubicCommand: = (.P1) && (.P2) && (.P3) case d2scene.ArcCommand: = (.P1) && !math.IsNaN(.RadiusX) && !math.IsInf(.RadiusX, 0) && !math.IsNaN(.RadiusY) && !math.IsInf(.RadiusY, 0) && !math.IsNaN(.Rotation) && !math.IsInf(.Rotation, 0) case d2scene.CloseCommand: default: = false } if ! { return .errorf("path command has a non-finite derived coordinate") } .commands = append(.commands, ) return .ctx.Err() } func ( *pathParser) () error { if .parsedCommands >= .maxCommands { return .errorf("path command count exceeds limit %d", .maxCommands) } .parsedCommands++ return .ctx.Err() } func ( *pathParser) ( string, ...any) error { return .errorAt(.offset, , ...) } func ( *pathParser) ( int, string, ...any) error { return fmt.Errorf("d2svgimport: %s path byte %d: %s", .source, , fmt.Sprintf(, ...)) } func displaySource( string) string { if == "" { return "SVG asset" } return } func reflectPoint(, d2scene.Point) d2scene.Point { return d2scene.Point{X: 2*.X - .X, Y: 2*.Y - .Y} } func previousUpper( byte) byte { if >= 'a' && <= 'z' { return - ('a' - 'A') } return } func isCommand( byte) bool { switch { case 'M', 'm', 'Z', 'z', 'L', 'l', 'H', 'h', 'V', 'v', 'C', 'c', 'S', 's', 'Q', 'q', 'T', 't', 'A', 'a': return true default: return false } } func isDigit( byte) bool { return >= '0' && <= '9' } func isWhitespace( byte) bool { switch { case ' ', '\t', '\n', '\r', '\f': return true default: return false } }