diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3661039..bea7669 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: "1.23" + go-version: "1.26" - name: Install swag run: go install github.com/swaggo/swag/cmd/swag@latest @@ -27,9 +27,9 @@ jobs: run: swag init - name: Run golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v7 with: - version: latest + version: v2.10 args: --timeout=5m build: @@ -43,7 +43,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: "1.23" + go-version: "1.26" - name: Install swag run: go install github.com/swaggo/swag/cmd/swag@latest @@ -71,7 +71,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: "1.23" + go-version: "1.26" - name: Cache Go modules uses: actions/cache@v3 diff --git a/.golangci.yml b/.golangci.yml index a1c6263..ede6edc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,21 +1,22 @@ +version: "2" + linters: enable: - - gofmt - govet - errcheck - staticcheck - unused - - gosimple - ineffassign - - typecheck + settings: + errcheck: + check-type-assertions: true + check-blank: true -linters-settings: - errcheck: - check-type-assertions: true - check-blank: true +formatters: + enable: + - gofmt issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/API.md b/API.md index 301570f..8c4e685 100644 --- a/API.md +++ b/API.md @@ -47,7 +47,7 @@ Export OpenSCAD content to various formats. | Field | Type | Required | Description | |-------|------|----------|-------------| | scad_content | string | Yes | The OpenSCAD code to export | -| format | string | Yes | Output format: `png`, `stl_binary`, `stl_ascii`, `svg`, `pdf`, `3mf` | +| format | string | Yes | Output format: `png`, `stl_binary`, `stl_ascii`, `svg`, `pdf`, `3mf`, `webp`, `avif` | | options | object | No | Format-specific options (see below) | #### Format-Specific Options @@ -59,6 +59,8 @@ Export OpenSCAD content to various formats. | width | integer | No | 800 | Image width in pixels | | height | integer | No | 600 | Image height in pixels | +> **Note:** WebP and AVIF formats reuse `options.png` for dimension customization. OpenSCAD renders to PNG first, then the server converts to the requested format. + ##### STL Options (`options.stl`) | Field | Type | Required | Default | Range | Description | @@ -297,6 +299,42 @@ curl -X POST http://localhost:8000/openscad/v1/export \ --output cube.3mf ``` +### Export a Cube to WebP + +```bash +curl -X POST http://localhost:8000/openscad/v1/export \ + -H "Content-Type: application/json" \ + -d '{ + "scad_content": "cube([10,10,10]);", + "format": "webp", + "options": { + "png": { + "width": 800, + "height": 600 + } + } + }' \ + --output cube.webp +``` + +### Export a Cube to AVIF + +```bash +curl -X POST http://localhost:8000/openscad/v1/export \ + -H "Content-Type: application/json" \ + -d '{ + "scad_content": "cube([10,10,10]);", + "format": "avif", + "options": { + "png": { + "width": 800, + "height": 600 + } + } + }' \ + --output cube.avif +``` + ### Generate Complete Summary ```bash @@ -366,6 +404,8 @@ Currently, no rate limiting is implemented. Consider adding rate limiting in pro | svg | `image/svg+xml` | | pdf | `application/pdf` | | 3mf | `application/vnd.ms-package.3dmodel+xml` | +| webp | `image/webp` | +| avif | `image/avif` | --- diff --git a/Dockerfile b/Dockerfile index e3eedde..351c5b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,11 @@ # Build stage -FROM golang:1.23-alpine AS builder +FROM golang:1.26-trixie AS builder WORKDIR /app # Install git for version info -RUN apk add --no-cache git +RUN apt-get update && apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* # Copy go mod files COPY go.mod go.sum ./ @@ -33,7 +34,8 @@ RUN set -e && \ FROM openscad/openscad:trixie # Install ca-certificates and wget for health checks -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates wget && \ rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/README.md b/README.md index 9e1f66b..3d85c60 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Hopefully others will find this useful as well. ## Features -- **Export to Multiple Formats**: PNG, STL (binary + ASCII), SVG, PDF, and 3MF +- **Export to Multiple Formats**: PNG, STL (binary + ASCII), SVG, PDF, 3MF, WebP, and AVIF - **Summary Generation**: Get diagnostics about SCAD models - **Format-Specific Options**: Supports a subset of format-specific parameters from the OpenSCAD CLI - **OpenAPI Documentation**: Interactive API docs @@ -33,7 +33,7 @@ http://localhost:8000/openscad/v1 POST /openscad/v1/export ``` -Exports OpenSCAD content to PNG, STL (binary/ASCII), SVG, PDF, or 3MF format. +Exports OpenSCAD content to PNG, STL (binary/ASCII), SVG, PDF, 3MF, WebP, or AVIF format. **Supported Formats:** @@ -43,6 +43,8 @@ Exports OpenSCAD content to PNG, STL (binary/ASCII), SVG, PDF, or 3MF format. - `svg` - Vector graphics - `pdf` - Document export - `3mf` - 3D Manufacturing Format (good option for more modern slicers) +- `webp` - WebP image (smaller file size than PNG) +- `avif` - AVIF image (modern format with excellent compression) #### 2. Generate Summary Information @@ -211,6 +213,42 @@ curl -X POST http://localhost:8000/openscad/v1/export \ --output square.pdf ``` +### Export to WebP + +```bash +curl -X POST http://localhost:8000/openscad/v1/export \ + -H "Content-Type: application/json" \ + -d '{ + "scad_content": "cube([10,10,10]);", + "format": "webp", + "options": { + "png": { + "width": 800, + "height": 600 + } + } + }' \ + --output cube.webp +``` + +### Export to AVIF + +```bash +curl -X POST http://localhost:8000/openscad/v1/export \ + -H "Content-Type: application/json" \ + -d '{ + "scad_content": "cube([10,10,10]);", + "format": "avif", + "options": { + "png": { + "width": 800, + "height": 600 + } + } + }' \ + --output cube.avif +``` + ### Generate Summary ```bash @@ -298,7 +336,9 @@ Common commands: │ └── handlers_test.go ├── services/ # Business logic │ ├── openscad.go -│ └── openscad_test.go +│ ├── openscad_test.go +│ ├── convert.go +│ └── convert_test.go ├── docs/ # Swagger documentation (generated) ├── Dockerfile # Docker configuration ├── justfile # Task runner configuration diff --git a/examples/README.md b/examples/README.md index 73335e6..a99816d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,6 +36,22 @@ curl -X POST http://localhost:8000/openscad/v1/export \ --output square.pdf ``` +### Export to WebP +```bash +curl -X POST http://localhost:8000/openscad/v1/export \ + -H "Content-Type: application/json" \ + -d @examples/export-webp.json \ + --output cube.webp +``` + +### Export to AVIF +```bash +curl -X POST http://localhost:8000/openscad/v1/export \ + -H "Content-Type: application/json" \ + -d @examples/export-avif.json \ + --output cube.avif +``` + ### Generate Summary ```bash curl -X POST http://localhost:8000/openscad/v1/summary \ diff --git a/examples/export-avif.json b/examples/export-avif.json new file mode 100644 index 0000000..649e64e --- /dev/null +++ b/examples/export-avif.json @@ -0,0 +1,10 @@ +{ + "scad_content": "cube([10,10,10]);", + "format": "avif", + "options": { + "png": { + "width": 800, + "height": 600 + } + } +} diff --git a/examples/export-webp.json b/examples/export-webp.json new file mode 100644 index 0000000..791c0f6 --- /dev/null +++ b/examples/export-webp.json @@ -0,0 +1,10 @@ +{ + "scad_content": "cube([10,10,10]);", + "format": "webp", + "options": { + "png": { + "width": 800, + "height": 600 + } + } +} diff --git a/go.mod b/go.mod index 5233bf1..94c0943 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,10 @@ module github.com/stevexciv/scad-server -go 1.23.0 +go 1.26 require ( + github.com/gen2brain/avif v0.4.4 + github.com/gen2brain/webp v0.5.5 github.com/gin-gonic/gin v1.11.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 @@ -16,6 +18,7 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/ebitengine/purego v0.9.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect @@ -38,6 +41,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/tetratelabs/wazero v1.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect @@ -46,7 +50,7 @@ require ( golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.9 // indirect diff --git a/go.sum b/go.sum index 8ed4ca5..35d6623 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gen2brain/avif v0.4.4 h1:Ga/ss7qcWWQm2bxFpnjYjhJsNfZrWs5RsyklgFjKRSE= +github.com/gen2brain/avif v0.4.4/go.mod h1:/XCaJcjZraQwKVhpu9aEd9aLOssYOawLvhMBtmHVGqk= +github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= +github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -97,6 +103,8 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -132,8 +140,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/handlers/handlers.go b/handlers/handlers.go index 343a961..71c2948 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -31,7 +31,7 @@ func NewHandlerWithService(exporter services.OpenSCADExporter) *Handler { // Export handles the export endpoint // @Summary Export SCAD to various formats -// @Description Exports OpenSCAD content to PNG, STL (binary/ASCII), SVG, or PDF format +// @Description Exports OpenSCAD content to PNG, STL (binary/ASCII), SVG, PDF, 3MF, WebP, or AVIF format // @Tags export // @Accept json // @Produce octet-stream diff --git a/handlers/handlers_test.go b/handlers/handlers_test.go index 2e090a2..fc84b24 100644 --- a/handlers/handlers_test.go +++ b/handlers/handlers_test.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -300,7 +301,7 @@ func TestSummaryEndpoint_SummaryTypeErrors(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mock := &MockOpenSCADExporter{ SummaryFunc: func(req *models.SummaryRequest) (*models.SummaryResponse, error) { - return nil, fmt.Errorf(tt.errMsg) + return nil, errors.New(tt.errMsg) }, } router := setupRouterWithMock(mock) @@ -351,13 +352,17 @@ func TestExportEndpoint_ValidFormats(t *testing.T) { contentType = "application/pdf" case "stl_binary", "stl_ascii": contentType = "application/octet-stream" + case "webp": + contentType = "image/webp" + case "avif": + contentType = "image/avif" } return []byte("mock export data"), contentType, nil }, } router := setupRouterWithMock(mock) - formats := []string{"png", "stl_binary", "stl_ascii", "svg", "pdf"} + formats := []string{"png", "stl_binary", "stl_ascii", "svg", "pdf", "webp", "avif"} for _, format := range formats { t.Run(format, func(t *testing.T) { @@ -443,7 +448,7 @@ func TestExportEndpoint_FormatSpecificErrors(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mock := &MockOpenSCADExporter{ ExportFunc: func(req *models.ExportRequest) ([]byte, string, error) { - return nil, "", fmt.Errorf(tt.errMsg) + return nil, "", errors.New(tt.errMsg) }, } router := setupRouterWithMock(mock) diff --git a/justfile b/justfile index 69373d9..911e147 100644 --- a/justfile +++ b/justfile @@ -44,7 +44,7 @@ clean: rm -rf bin/ rm -rf tmp/ rm -f coverage.out coverage.html - rm -f *.stl *.png *.svg *.pdf *.scad + rm -f *.stl *.png *.svg *.pdf *.scad *.webp *.avif # Build Docker image docker-build: diff --git a/models/models.go b/models/models.go index f41111f..1617e4a 100644 --- a/models/models.go +++ b/models/models.go @@ -16,7 +16,8 @@ type ExportOptions struct { ThreeMF *ThreeMFOptions `json:"3mf,omitempty"` } -// PNGOptions contains PNG export options +// PNGOptions contains PNG export options. +// Also used for webp and avif formats (which render via PNG internally). type PNGOptions struct { Width *int `json:"width,omitempty" example:"800"` Height *int `json:"height,omitempty" example:"600"` diff --git a/services/convert.go b/services/convert.go new file mode 100644 index 0000000..51a96a3 --- /dev/null +++ b/services/convert.go @@ -0,0 +1,43 @@ +package services + +import ( + "bytes" + "fmt" + "image/png" + "log" + + "github.com/gen2brain/avif" + "github.com/gen2brain/webp" +) + +// convertPNGToWebP takes raw PNG bytes and returns WebP-encoded bytes. +func convertPNGToWebP(pngData []byte) ([]byte, error) { + img, err := png.Decode(bytes.NewReader(pngData)) + if err != nil { + return nil, fmt.Errorf("failed to decode PNG: %w", err) + } + + var buf bytes.Buffer + if err := webp.Encode(&buf, img, webp.Options{Quality: 80}); err != nil { + return nil, fmt.Errorf("failed to encode WebP: %w", err) + } + + log.Printf("[Convert] PNG (%d bytes) -> WebP (%d bytes)", len(pngData), buf.Len()) + return buf.Bytes(), nil +} + +// convertPNGToAVIF takes raw PNG bytes and returns AVIF-encoded bytes. +func convertPNGToAVIF(pngData []byte) ([]byte, error) { + img, err := png.Decode(bytes.NewReader(pngData)) + if err != nil { + return nil, fmt.Errorf("failed to decode PNG: %w", err) + } + + var buf bytes.Buffer + if err := avif.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("failed to encode AVIF: %w", err) + } + + log.Printf("[Convert] PNG (%d bytes) -> AVIF (%d bytes)", len(pngData), buf.Len()) + return buf.Bytes(), nil +} diff --git a/services/convert_test.go b/services/convert_test.go new file mode 100644 index 0000000..0481cdd --- /dev/null +++ b/services/convert_test.go @@ -0,0 +1,93 @@ +package services + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" +) + +// createTestPNG creates a minimal valid PNG image for testing. +func createTestPNG(width, height int) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func TestConvertPNGToWebP(t *testing.T) { + t.Run("Valid PNG", func(t *testing.T) { + pngData, err := createTestPNG(4, 4) + if err != nil { + t.Fatalf("Failed to create test PNG: %v", err) + } + + webpData, err := convertPNGToWebP(pngData) + if err != nil { + t.Fatalf("convertPNGToWebP() error = %v", err) + } + + if len(webpData) == 0 { + t.Error("Expected non-empty WebP output") + } + + // WebP files start with "RIFF" magic bytes + if len(webpData) < 12 { + t.Fatalf("WebP output too short: %d bytes", len(webpData)) + } + if string(webpData[0:4]) != "RIFF" { + t.Errorf("Expected RIFF magic bytes, got %q", webpData[0:4]) + } + if string(webpData[8:12]) != "WEBP" { + t.Errorf("Expected WEBP signature, got %q", webpData[8:12]) + } + }) + + t.Run("Invalid input", func(t *testing.T) { + _, err := convertPNGToWebP([]byte("not a png")) + if err == nil { + t.Error("Expected error for invalid PNG input") + } + }) +} + +func TestConvertPNGToAVIF(t *testing.T) { + t.Run("Valid PNG", func(t *testing.T) { + pngData, err := createTestPNG(4, 4) + if err != nil { + t.Fatalf("Failed to create test PNG: %v", err) + } + + avifData, err := convertPNGToAVIF(pngData) + if err != nil { + t.Fatalf("convertPNGToAVIF() error = %v", err) + } + + if len(avifData) == 0 { + t.Error("Expected non-empty AVIF output") + } + + // AVIF files contain an "ftyp" box near the start + if len(avifData) < 12 { + t.Fatalf("AVIF output too short: %d bytes", len(avifData)) + } + if string(avifData[4:8]) != "ftyp" { + t.Errorf("Expected ftyp box, got %q", avifData[4:8]) + } + }) + + t.Run("Invalid input", func(t *testing.T) { + _, err := convertPNGToAVIF([]byte("not a png")) + if err == nil { + t.Error("Expected error for invalid PNG input") + } + }) +} diff --git a/services/openscad.go b/services/openscad.go index ac6044b..2d787e3 100644 --- a/services/openscad.go +++ b/services/openscad.go @@ -106,6 +106,22 @@ func (s *OpenSCADService) Export(req *models.ExportRequest) ([]byte, string, err } log.Printf("[OpenSCAD Export] Output file read successfully, size: %d bytes", len(data)) + // Post-process: convert PNG to target format if needed + switch req.Format { + case "webp": + data, err = convertPNGToWebP(data) + if err != nil { + log.Printf("[OpenSCAD Export] Failed to convert to WebP: %v", err) + return nil, "", fmt.Errorf("failed to convert to WebP: %w", err) + } + case "avif": + data, err = convertPNGToAVIF(data) + if err != nil { + log.Printf("[OpenSCAD Export] Failed to convert to AVIF: %v", err) + return nil, "", fmt.Errorf("failed to convert to AVIF: %w", err) + } + } + // Get content type contentType := s.getContentType(req.Format) @@ -176,6 +192,8 @@ func (s *OpenSCADService) validateFormat(format string) error { "svg": true, "pdf": true, "3mf": true, + "webp": true, + "avif": true, } if !validFormats[format] { @@ -199,6 +217,9 @@ func (s *OpenSCADService) getOutputExtension(format string) (string, string) { return "pdf", "" case "3mf": return "3mf", "" + case "webp", "avif": + // Render as PNG first, then convert to target format + return "png", "" default: return "", "" } @@ -208,7 +229,7 @@ func (s *OpenSCADService) buildExportOptions(req *models.ExportRequest) []string var args []string switch req.Format { - case "png": + case "png", "webp", "avif": if req.Options.PNG != nil { if req.Options.PNG.Width != nil || req.Options.PNG.Height != nil { width := 800 @@ -334,6 +355,10 @@ func (s *OpenSCADService) getContentType(format string) string { return "application/pdf" case "3mf": return "application/vnd.ms-package.3dmodel+xml" + case "webp": + return "image/webp" + case "avif": + return "image/avif" default: return "application/octet-stream" } diff --git a/services/openscad_test.go b/services/openscad_test.go index a59c19e..0a8be1a 100644 --- a/services/openscad_test.go +++ b/services/openscad_test.go @@ -20,6 +20,8 @@ func TestValidateFormat(t *testing.T) { {"Valid SVG", "svg", false}, {"Valid PDF", "pdf", false}, {"Valid 3MF", "3mf", false}, + {"Valid WebP", "webp", false}, + {"Valid AVIF", "avif", false}, {"Invalid format", "invalid", true}, {"Empty format", "", true}, } @@ -49,6 +51,8 @@ func TestGetOutputExtension(t *testing.T) { {"SVG", "svg", "svg", ""}, {"PDF", "pdf", "pdf", ""}, {"3MF", "3mf", "3mf", ""}, + {"WebP", "webp", "png", ""}, + {"AVIF", "avif", "png", ""}, } for _, tt := range tests { @@ -78,6 +82,8 @@ func TestGetContentType(t *testing.T) { {"SVG", "svg", "image/svg+xml"}, {"PDF", "pdf", "application/pdf"}, {"3MF", "3mf", "application/vnd.ms-package.3dmodel+xml"}, + {"WebP", "webp", "image/webp"}, + {"AVIF", "avif", "image/avif"}, {"Unknown", "unknown", "application/octet-stream"}, } @@ -156,6 +162,54 @@ func TestBuildExportOptions(t *testing.T) { } }) + t.Run("WebP options (reuses PNG)", func(t *testing.T) { + width := 800 + height := 600 + req := &models.ExportRequest{ + Format: "webp", + Options: models.ExportOptions{ + PNG: &models.PNGOptions{ + Width: &width, + Height: &height, + }, + }, + } + args := service.buildExportOptions(req) + if len(args) != 2 { + t.Errorf("Expected 2 args, got %d", len(args)) + } + if args[0] != "--imgsize" { + t.Errorf("Expected --imgsize, got %s", args[0]) + } + if args[1] != "800,600" { + t.Errorf("Expected 800,600, got %s", args[1]) + } + }) + + t.Run("AVIF options (reuses PNG)", func(t *testing.T) { + width := 640 + height := 480 + req := &models.ExportRequest{ + Format: "avif", + Options: models.ExportOptions{ + PNG: &models.PNGOptions{ + Width: &width, + Height: &height, + }, + }, + } + args := service.buildExportOptions(req) + if len(args) != 2 { + t.Errorf("Expected 2 args, got %d", len(args)) + } + if args[0] != "--imgsize" { + t.Errorf("Expected --imgsize, got %s", args[0]) + } + if args[1] != "640,480" { + t.Errorf("Expected 640,480, got %s", args[1]) + } + }) + t.Run("No options", func(t *testing.T) { req := &models.ExportRequest{ Format: "png",