diff --git a/example_test.go b/example_test.go index 26826a6..5a9a33c 100644 --- a/example_test.go +++ b/example_test.go @@ -66,8 +66,8 @@ func Example_background() { // width: 400 height: 180 } -func Example_bestFit() { - a, err := resvg.Render(svgData, resvg.WithBestFit(true), resvg.WithWidth(300)) +func Example_scaleBestFit() { + a, err := resvg.Render(svgData, resvg.WithScaleMode(resvg.ScaleBestFit), resvg.WithWidth(300)) if err != nil { log.Fatal(err) } @@ -80,11 +80,11 @@ func Example_bestFit() { if err := os.WriteFile("rect_bestfit_a.png", buf.Bytes(), 0o644); err != nil { log.Fatal(err) } - b, err := resvg.Render(svgData, resvg.WithBestFit(true), resvg.WithHeight(135)) + b, err := resvg.Render(svgData, resvg.WithScaleMode(resvg.ScaleBestFit), resvg.WithHeight(135)) if err != nil { log.Fatal(err) } - bb := a.Bounds() + bb := b.Bounds() fmt.Printf("width: %d height: %d\n", bb.Max.X, bb.Max.Y) buf.Reset() if err := png.Encode(buf, b); err != nil { @@ -98,7 +98,7 @@ func Example_bestFit() { // width: 300 height: 135 } -func Example_scale() { +func Example_distort() { img, err := resvg.Render(svgData, resvg.WithWidth(200), resvg.WithHeight(700)) if err != nil { log.Fatal(err) @@ -109,7 +109,7 @@ func Example_scale() { if err := png.Encode(buf, img); err != nil { log.Fatal(err) } - if err := os.WriteFile("rect_scale.png", buf.Bytes(), 0o644); err != nil { + if err := os.WriteFile("rect_distort.png", buf.Bytes(), 0o644); err != nil { log.Fatal(err) } // Output: diff --git a/resvg.go b/resvg.go index 457a311..ce1a8a4 100644 --- a/resvg.go +++ b/resvg.go @@ -41,7 +41,6 @@ void render(resvg_render_tree* tree, int width, int height, resvg_transform ts, import "C" import ( - "errors" "fmt" "image" "image/color" @@ -75,10 +74,10 @@ type Resvg struct { fonts [][]byte fontFiles []string background color.Color - width int - height int - bestFit bool - transform []float64 + width uint + height uint + scaleMode ScaleMode + transform []float32 opts *C.resvg_options once sync.Once } @@ -87,9 +86,9 @@ type Resvg struct { func New(opts ...Option) *Resvg { r := &Resvg{ loadSystemFonts: true, - shapeRendering: ShapeRenderingNotSet, - textRendering: TextRenderingNotSet, - imageRendering: ImageRenderingNotSet, + shapeRendering: shapeRenderingNotSet, + textRendering: textRenderingNotSet, + imageRendering: imageRenderingNotSet, background: color.Transparent, } for _, o := range opts { @@ -99,6 +98,86 @@ func New(opts ...Option) *Resvg { return r } +// ParseConfig parses the svg, returning an image config. +func (r *Resvg) ParseConfig(data []byte) (image.Config, error) { + tree, width, height, _, _, err := r.parse(data) + if err != nil { + return image.Config{}, err + } + // destroy + C.resvg_tree_destroy(tree) + return image.Config{ + ColorModel: color.RGBAModel, + Width: width, + Height: height, + }, nil +} + +// Render renders svg data as a RGBA image. +func (r *Resvg) Render(data []byte) (*image.RGBA, error) { + tree, width, height, scaleX, scaleY, err := r.parse(data) + if err != nil { + return nil, err + } + // build transform + ts := C.resvg_transform_identity() + if r.transform == nil { + ts.a, ts.d = C.float(scaleX), C.float(scaleY) + } else { + ts.a = C.float(r.transform[0]) + ts.b = C.float(r.transform[1]) + ts.c = C.float(r.transform[2]) + ts.d = C.float(r.transform[3]) + ts.e = C.float(r.transform[4]) + ts.f = C.float(r.transform[5]) + } + // background + img := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + if c := color.RGBAModel.Convert(r.background).(color.RGBA); c.R != 0 || c.G != 0 || c.B != 0 || c.A != 0 { + for i := 0; i < width; i++ { + for j := 0; j < height; j++ { + img.SetRGBA(i, j, c) + } + } + } + // render + C.render(tree, C.int(width), C.int(height), ts, img.Pix) + // destroy + C.resvg_tree_destroy(tree) + return img, nil +} + +// parse parses the svg data, returning the width, height, and scaling factors. +func (r *Resvg) parse(data []byte) (*C.resvg_render_tree, int, int, float32, float32, error) { + r.once.Do(r.buildOpts) + if r.opts == nil { + return nil, 0, 0, 0.0, 0.0, ErrOptionsNotInitialized + } + // parse + tree, err := C.parse(data, r.opts) + if err != nil { + return nil, 0, 0, 0.0, 0.0, newErrNo(err) + } + // dimensions + size := C.resvg_get_image_size(tree) + if size.width == 0 || size.height == 0 { + return nil, 0, 0, 0.0, 0.0, ErrInvalidWidthOrHeight + } + // determine height, width, scaleX, scaleY + width, height, scaleX, scaleY := r.scaleMode.Scale(uint(size.width), uint(size.height), r.width, r.height) + switch { + case width == 0: + return nil, 0, 0, 0.0, 0.0, ErrInvalidWidth + case height == 0: + return nil, 0, 0, 0.0, 0.0, ErrInvalidHeight + case scaleX == 0.0: + return nil, 0, 0, 0.0, 0.0, ErrInvalidXScale + case scaleY == 0.0: + return nil, 0, 0, 0.0, 0.0, ErrInvalidYScale + } + return tree, width, height, scaleX, scaleY, nil +} + // buildOpts builds the resvg options. func (r *Resvg) buildOpts() { opts := C.resvg_options_create() @@ -151,13 +230,13 @@ func (r *Resvg) buildOpts() { C.resvg_options_set_languages(opts, s) C.free(unsafe.Pointer(s)) } - if r.shapeRendering != ShapeRenderingNotSet { + if r.shapeRendering != shapeRenderingNotSet { C.resvg_options_set_shape_rendering_mode(opts, C.resvg_shape_rendering(r.shapeRendering)) } - if r.textRendering != TextRenderingNotSet { + if r.textRendering != textRenderingNotSet { C.resvg_options_set_text_rendering_mode(opts, C.resvg_text_rendering(r.textRendering)) } - if r.imageRendering != ImageRenderingNotSet { + if r.imageRendering != imageRenderingNotSet { C.resvg_options_set_image_rendering_mode(opts, C.resvg_image_rendering(r.imageRendering)) } for _, font := range r.fonts { @@ -184,102 +263,6 @@ func (r *Resvg) finalize() { runtime.SetFinalizer(r, nil) } -// ParseConfig parses the svg, returning an image config. -func (r *Resvg) ParseConfig(data []byte) (image.Config, error) { - r.once.Do(r.buildOpts) - if r.opts == nil { - return image.Config{}, errors.New("options not initialized") - } - tree, errno := C.parse(data, r.opts) - if errno != nil { - return image.Config{}, NewParseError(errno) - } - // height/width - size := C.resvg_get_image_size(tree) - width, height := int(size.width), int(size.height) - // destroy - C.resvg_tree_destroy(tree) - return image.Config{ - ColorModel: color.RGBAModel, - Width: width, - Height: height, - }, nil -} - -// Render renders svg data as a RGBA image. -func (r *Resvg) Render(data []byte) (*image.RGBA, error) { - r.once.Do(r.buildOpts) - if r.opts == nil { - return nil, errors.New("options not initialized") - } - tree, errno := C.parse(data, r.opts) - if errno != nil { - return nil, NewParseError(errno) - } - // determine height, width, scaleX, scaleY - size := C.resvg_get_image_size(tree) - if size.width == 0 || size.height == 0 { - return nil, errors.New("invalid width or height") - } - width, height, scaleX, scaleY := r.calc(int(size.width), int(size.height)) - switch { - case width == 0: - return nil, errors.New("invalid width") - case height == 0: - return nil, errors.New("invalid height") - case scaleX == 0.0: - return nil, errors.New("invalid x scale") - case scaleY == 0.0: - return nil, errors.New("invalid y scale") - } - // build transform - ts := C.resvg_transform_identity() - if r.transform != nil { - ts.a = C.float(r.transform[0]) - ts.b = C.float(r.transform[1]) - ts.c = C.float(r.transform[2]) - ts.d = C.float(r.transform[3]) - ts.e = C.float(r.transform[4]) - ts.f = C.float(r.transform[5]) - } else { - ts.a, ts.d = C.float(scaleX), C.float(scaleY) - } - c := color.RGBAModel.Convert(r.background).(color.RGBA) - // build out - img := image.NewRGBA(image.Rect(0, 0, width, height)) - for i := 0; i < width; i++ { - for j := 0; j < height; j++ { - img.SetRGBA(i, j, c) - } - } - // render - C.render(tree, C.int(width), C.int(height), ts, img.Pix) - // destroy - C.resvg_tree_destroy(tree) - return img, nil -} - -// calc determines the width/height and scales in the x/y direction to use. -func (r *Resvg) calc(width, height int) (int, int, float32, float32) { - hasWidth, hasHeight := r.width != 0, r.height != 0 - switch { - case hasWidth && hasHeight: - return r.width, r.height, float32(r.width) / float32(width), float32(r.height) / float32(height) - case !r.bestFit && hasWidth: - return r.width, height, float32(r.width) / float32(width), 1.0 - case !r.bestFit && hasHeight: - return width, r.height, 1.0, float32(r.height) / float32(height) - case !r.bestFit: - return width, height, 1.0, 1.0 - } - if hasWidth { - scaleX := float32(r.width) / float32(width) - return r.width, int(math.Round(float64(float32(height) * scaleX))), scaleX, scaleX - } - scaleY := float32(r.height) / float32(height) - return int(math.Round(float64(float32(width) * scaleY))), r.height, scaleY, scaleY -} - // ShapeRendering is the shape rendering mode. type ShapeRendering int @@ -288,7 +271,7 @@ const ( ShapeRenderingOptimizeSpeed ShapeRendering = C.RESVG_SHAPE_RENDERING_OPTIMIZE_SPEED ShapeRenderingCrispEdges ShapeRendering = C.RESVG_SHAPE_RENDERING_CRISP_EDGES ShapeRenderingGeometricPrecision ShapeRendering = C.RESVG_SHAPE_RENDERING_GEOMETRIC_PRECISION - ShapeRenderingNotSet ShapeRendering = 0xff + shapeRenderingNotSet ShapeRendering = 0xff ) // TextRendering is the text rendering mode. @@ -299,7 +282,7 @@ const ( TextRenderingOptimizeSpeed TextRendering = C.RESVG_TEXT_RENDERING_OPTIMIZE_SPEED TextRenderingOptimizeLegibility TextRendering = C.RESVG_TEXT_RENDERING_OPTIMIZE_LEGIBILITY TextRenderingGeometricPrecision TextRendering = C.RESVG_TEXT_RENDERING_GEOMETRIC_PRECISION - TextRenderingNotSet TextRendering = 0xff + textRenderingNotSet TextRendering = 0xff ) // ImageRendering is the image rendering mode. @@ -309,42 +292,129 @@ type ImageRendering int const ( ImageRenderingOptimizeQuality ImageRendering = C.RESVG_IMAGE_RENDERING_OPTIMIZE_QUALITY ImageRenderingOptimizeSpeed ImageRendering = C.RESVG_IMAGE_RENDERING_OPTIMIZE_SPEED - ImageRenderingNotSet ImageRendering = 0xff + imageRenderingNotSet ImageRendering = 0xff ) -// ParseError is a parse error. -type ParseError int +// ScaleMode is a scale mode. +type ScaleMode uint8 + +// Scale modes. +const ( + ScaleNone ScaleMode = iota + ScaleMinWidth + ScaleMinHeight + ScaleMaxWidth + ScaleMaxHeight + ScaleBestFit +) + +// Scale calculates the scale for the width, height. +func (mode ScaleMode) Scale(width, height, w, h uint) (int, int, float32, float32) { + switch mode { + case ScaleMinWidth: + return scaleWidth(width, height, w, h, width < w) + case ScaleMinHeight: + return scaleHeight(width, height, w, h, height < h) + case ScaleMaxWidth: + return scaleWidth(width, height, w, h, width > w) + case ScaleMaxHeight: + return scaleHeight(width, height, w, h, height > h) + case ScaleBestFit: + return scaleBestFit(width, height, w, h) + } + scaleX, scaleY := float32(1.0), float32(1.0) + if w != 0 { + width, scaleX = w, float32(w)/float32(width) + } + if h != 0 { + height, scaleY = h, float32(h)/float32(height) + } + return int(width), int(height), scaleX, scaleY +} + +// scaleWidth calculates the scale for a width. +func scaleWidth(width, height, w, _ uint, scale bool) (int, int, float32, float32) { + if scale { + scaleX := float32(w) / float32(width) + return int(w), int(math.Round(float64(float32(height) * scaleX))), scaleX, scaleX + } + return int(width), int(height), 1.0, 1.0 +} + +// scaleHeight calculates the scale for a height. +func scaleHeight(width, height, _, h uint, scale bool) (int, int, float32, float32) { + if scale { + scaleY := float32(h) / float32(height) + return int(math.Round(float64(float32(width) * scaleY))), int(h), scaleY, scaleY + } + return int(width), int(height), 1.0, 1.0 +} + +// scaleBestFit calculates the best fit scale for the width, height. +func scaleBestFit(width, height, w, h uint) (int, int, float32, float32) { + var scale float32 + switch { + case w == 0: + scale = float32(h) / float32(height) + case h == 0: + scale = float32(w) / float32(width) + default: + scale = min(float32(w)/float32(width), float32(h)/float32(height)) + } + return int(math.Round(float64(float32(width) * scale))), int(math.Round(float64(float32(height) * scale))), scale, scale +} + +// Error is a package error. +type Error string + +// Errors. +const ( + ErrOptionsNotInitialized Error = "options not initialized" + ErrInvalidWidthOrHeight Error = "invalid width or height" + ErrInvalidWidth Error = "invalid width" + ErrInvalidHeight Error = "invalid height" + ErrInvalidXScale Error = "invalid x scale" + ErrInvalidYScale Error = "invalid y scale" +) + +// Error satisfies the [error] interface. +func (err Error) Error() string { + return string(err) +} + +// ErrNo wraps a resvg error. +type ErrNo int -// NewParseError creates a new error. -func NewParseError(e error) error { - if e == nil { +// newErrNo creates a new error. +func newErrNo(err error) error { + if err == nil { return nil } - if se, ok := e.(syscall.Errno); ok { - return ParseError(int(se)) + if se, ok := err.(syscall.Errno); ok { + return ErrNo(int(se)) } - panic(fmt.Sprintf("invalid error type: %T", e)) + panic(fmt.Sprintf("invalid error type %T", err)) } // Error satisfies the [error] interface. -func (err ParseError) Error() string { +func (err ErrNo) Error() string { switch err { case C.RESVG_OK: - return "OK" + return "resvg: ok" case C.RESVG_ERROR_NOT_AN_UTF8_STR: - return "only UTF-8 content are supported" + return "resvg: not a utf8 string" case C.RESVG_ERROR_FILE_OPEN_FAILED: - return "failed to open the provided file" + return "resvg: file open failed" case C.RESVG_ERROR_MALFORMED_GZIP: - return "compressed SVG must use the GZip algorithm" + return "resvg: malformed gzip data" case C.RESVG_ERROR_ELEMENTS_LIMIT_REACHED: - return "we do not allow SVG with more than 1_000_000 elements for security reasons" + return "resvg: element limit reached" case C.RESVG_ERROR_INVALID_SIZE: - return "SVG doesn't have a valid size" + return "resvg: invalid size" case C.RESVG_ERROR_PARSING_FAILED: - return "failed to parse SVG data" + return "resvg: parsing failed" } - return "" + return fmt.Sprintf("resvg: unknown error %d", int(err)) } // Option is a resvg rendering option. @@ -465,28 +535,28 @@ func WithBackground(background color.Color) Option { // WithWidth is a resvg option to set the width. func WithWidth(width int) Option { return func(r *Resvg) { - r.width = width + r.width = uint(width) } } // WithHeight is a resvg option to set the height. func WithHeight(height int) Option { return func(r *Resvg) { - r.height = height + r.height = uint(height) } } -// WithBestFit is a resvg option to set best fit. -func WithBestFit(bestFit bool) Option { +// WithScaleMode is a resvg option to set scale mode. +func WithScaleMode(scaleMode ScaleMode) Option { return func(r *Resvg) { - r.bestFit = bestFit + r.scaleMode = scaleMode } } // WithTransform is a resvg option to set the transform used. -func WithTransform(a, b, c, d, e, f float64) Option { +func WithTransform(a, b, c, d, e, f float32) Option { return func(r *Resvg) { - r.transform = []float64{a, b, c, d, e, f} + r.transform = []float32{a, b, c, d, e, f} } } diff --git a/resvg_test.go b/resvg_test.go index 6ac1330..d26b1a3 100644 --- a/resvg_test.go +++ b/resvg_test.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "strconv" "strings" "testing" ) @@ -50,7 +51,7 @@ func testRender(t *testing.T, name string) { } var opts []Option if name == "testdata/folder.svg" { - opts = append(opts, WithBestFit(true), WithWidth(200)) + opts = append(opts, WithScaleMode(ScaleBestFit), WithWidth(200)) } img, err := Render(data, opts...) if err != nil { @@ -87,6 +88,51 @@ func testRender(t *testing.T, name string) { } } +func TestScale(t *testing.T) { + tests := []struct { + mode ScaleMode + w, h, ww, wh uint + expw int + exph int + expx float32 + expy float32 + }{ + {ScaleNone, 100, 100, 0, 0, 100, 100, 1.0, 1.0}, + {ScaleNone, 100, 100, 200, 50, 200, 50, 2.0, 0.5}, + {ScaleNone, 100, 100, 50, 0, 50, 100, 0.5, 1.0}, + {ScaleNone, 100, 100, 0, 200, 100, 200, 1.0, 2.0}, + {ScaleMinWidth, 100, 100, 200, 0, 200, 200, 2.0, 2.0}, + {ScaleMinWidth, 1000, 1000, 200, 0, 1000, 1000, 1.0, 1.0}, + {ScaleMaxWidth, 100, 100, 200, 0, 100, 100, 1.0, 1.0}, + {ScaleMaxWidth, 1000, 1000, 500, 0, 500, 500, 0.5, 0.5}, + {ScaleMinHeight, 100, 100, 0, 200, 200, 200, 2.0, 2.0}, + {ScaleMinHeight, 1000, 1000, 0, 200, 1000, 1000, 1.0, 1.0}, + {ScaleMaxHeight, 100, 100, 0, 200, 100, 100, 1.0, 1.0}, + {ScaleMaxHeight, 1000, 1000, 0, 500, 500, 500, 0.5, 0.5}, + {ScaleBestFit, 100, 100, 960, 1000, 960, 960, 9.6, 9.6}, + {ScaleBestFit, 100, 100, 1000, 960, 960, 960, 9.6, 9.6}, + {ScaleBestFit, 1000, 1000, 200, 300, 200, 200, 0.2, 0.2}, + {ScaleBestFit, 1000, 5000, 100, 200, 40, 200, 0.04, 0.04}, + } + for i, test := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + w, h, x, y := test.mode.Scale(test.w, test.h, test.ww, test.wh) + if w != test.expw { + t.Errorf("expected w %d, got: %d", test.expw, w) + } + if h != test.exph { + t.Errorf("expected h %d, got: %d", test.exph, h) + } + if x != test.expx { + t.Errorf("expected x %f, got: %f", test.expx, x) + } + if y != test.expy { + t.Errorf("expected y %f, got: %f", test.expy, y) + } + }) + } +} + func cleanString(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "v") }