1
0
Эх сурвалжийг харах

IMG-23: split imagetype (#1484)

* Image type (new)

* bufreader, RegisterFormat updated

* Remove StemExt

* Unknown = 0

* Joined imagedetect and imagetype

* Use regular []byte in bufreader
Victor Sokolov 1 сар өмнө
parent
commit
2cb9aea995

+ 38 - 63
bufreader/bufreader.go

@@ -1,104 +1,79 @@
+// bufreader provides a buffered reader that reads from io.Reader, but caches
+// the data in a bytes.Buffer to allow peeking and discarding without re-reading.
 package bufreader
 
 import (
-	"bufio"
-	"bytes"
 	"io"
-
-	"github.com/imgproxy/imgproxy/v3/imath"
 )
 
+// ReadPeeker is an interface that combines io.Reader and a method to peek at the next n bytes
+type ReadPeeker interface {
+	io.Reader
+	Peek(n int) ([]byte, error) // Peek returns the next n bytes without advancing
+}
+
+// Reader is a buffered reader that reads from an io.Reader and caches the data.
 type Reader struct {
 	r   io.Reader
-	buf *bytes.Buffer
-	cur int
+	buf []byte
+	pos int
 }
 
-func New(r io.Reader, buf *bytes.Buffer) *Reader {
+// New creates new buffered reader
+func New(r io.Reader) *Reader {
 	br := Reader{
 		r:   r,
-		buf: buf,
+		buf: nil,
 	}
 	return &br
 }
 
+// Read reads data into p from the buffered reader.
 func (br *Reader) Read(p []byte) (int, error) {
-	if err := br.fill(br.cur + len(p)); err != nil {
-		return 0, err
-	}
-
-	n := copy(p, br.buf.Bytes()[br.cur:])
-	br.cur += n
-	return n, nil
-}
-
-func (br *Reader) ReadByte() (byte, error) {
-	if err := br.fill(br.cur + 1); err != nil {
-		return 0, err
-	}
-
-	b := br.buf.Bytes()[br.cur]
-	br.cur++
-	return b, nil
-}
-
-func (br *Reader) Discard(n int) (int, error) {
-	if n < 0 {
-		return 0, bufio.ErrNegativeCount
-	}
-	if n == 0 {
-		return 0, nil
-	}
-
-	if err := br.fill(br.cur + n); err != nil {
+	if err := br.fetch(br.pos + len(p)); err != nil {
 		return 0, err
 	}
 
-	n = imath.Min(n, br.buf.Len()-br.cur)
-	br.cur += n
+	n := copy(p, br.buf[br.pos:])
+	br.pos += n
 	return n, nil
 }
 
+// Peek returns the next n bytes from the buffered reader without advancing the position.
 func (br *Reader) Peek(n int) ([]byte, error) {
-	if n < 0 {
-		return []byte{}, bufio.ErrNegativeCount
-	}
-	if n == 0 {
-		return []byte{}, nil
-	}
-
-	if err := br.fill(br.cur + n); err != nil {
-		return []byte{}, err
+	err := br.fetch(br.pos + n)
+	if err != nil && err != io.EOF {
+		return nil, err
 	}
 
-	if n > br.buf.Len()-br.cur {
-		return br.buf.Bytes()[br.cur:], io.EOF
+	// Return slice of buffered data without advancing position
+	available := br.buf[br.pos:]
+	if len(available) == 0 && err == io.EOF {
+		return nil, io.EOF
 	}
 
-	return br.buf.Bytes()[br.cur : br.cur+n], nil
+	return available[:min(len(available), n)], nil
 }
 
-func (br *Reader) Flush() error {
-	_, err := br.buf.ReadFrom(br.r)
-	return err
+// Rewind seeks buffer to the beginning
+func (br *Reader) Rewind() {
+	br.pos = 0
 }
 
-func (br *Reader) fill(need int) error {
-	n := need - br.buf.Len()
-	if n <= 0 {
+// fetch ensures the buffer contains at least 'need' bytes
+func (br *Reader) fetch(need int) error {
+	if need-len(br.buf) <= 0 {
 		return nil
 	}
 
-	n = imath.Max(4096, n)
-
-	if _, err := br.buf.ReadFrom(io.LimitReader(br.r, int64(n))); err != nil {
+	b := make([]byte, need)
+	n, err := io.ReadFull(br.r, b)
+	if err != nil && err != io.ErrUnexpectedEOF {
 		return err
 	}
 
-	// Nothing was read, it's EOF
-	if br.cur == br.buf.Len() {
-		return io.EOF
-	}
+	// append only those which we read in fact
+	br.buf = append(br.buf, b[:n]...)
 
 	return nil
 }

+ 97 - 0
bufreader/bufreader_test.go

@@ -0,0 +1,97 @@
+package bufreader
+
+import (
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+)
+
+// BufferedReaderTestSuite defines the test suite for the buffered reader
+type BufferedReaderTestSuite struct {
+	suite.Suite
+}
+
+func (s *BufferedReaderTestSuite) TestRead() {
+	data := "hello world"
+	br := New(strings.NewReader(data))
+
+	// First read
+	p1 := make([]byte, 5)
+	n1, err1 := br.Read(p1)
+	s.Require().NoError(err1)
+	s.Equal(5, n1)
+	s.Equal("hello", string(p1))
+
+	// Second read
+	p2 := make([]byte, 6)
+	n2, err2 := br.Read(p2)
+	s.Require().NoError(err2)
+	s.Equal(6, n2)
+	s.Equal(" world", string(p2))
+
+	// Verify position
+	s.Equal(11, br.pos)
+}
+
+func (s *BufferedReaderTestSuite) TestEOF() {
+	data := "hello"
+	br := New(strings.NewReader(data))
+
+	// Read all data
+	p1 := make([]byte, 5)
+	n1, err1 := br.Read(p1)
+	s.Require().NoError(err1)
+	s.Equal(5, n1)
+	s.Equal("hello", string(p1))
+
+	// Try to read more - should get EOF
+	p2 := make([]byte, 5)
+	n2, err2 := br.Read(p2)
+	s.Equal(io.EOF, err2)
+	s.Equal(0, n2)
+}
+
+func (s *BufferedReaderTestSuite) TestEOF_WhenDataExhausted() {
+	data := "hello"
+	br := New(strings.NewReader(data))
+
+	// Try to read more than available
+	p := make([]byte, 10)
+	n, err := br.Read(p)
+
+	s.Require().NoError(err)
+	s.Equal(5, n)
+	s.Equal("hello", string(p[:n]))
+}
+
+func (s *BufferedReaderTestSuite) TestPeek() {
+	data := "hello world"
+	br := New(strings.NewReader(data))
+
+	// Peek at first 5 bytes
+	peeked, err := br.Peek(5)
+	s.Require().NoError(err)
+	s.Equal("hello", string(peeked))
+	s.Equal(0, br.pos) // Position should not change
+
+	// Read the same data to verify peek didn't consume it
+	p := make([]byte, 5)
+	n, err := br.Read(p)
+	s.Require().NoError(err)
+	s.Equal(5, n)
+	s.Equal("hello", string(p))
+	s.Equal(5, br.pos) // Position should now be updated
+
+	// Peek at the next 7 bytes (which are beyond the EOF)
+	peeked2, err := br.Peek(7)
+	s.Require().NoError(err)
+	s.Equal(" world", string(peeked2))
+	s.Equal(5, br.pos) // Position should still be 5
+}
+
+// TestBufferedReaderSuite runs the test suite
+func TestBufferedReader(t *testing.T) {
+	suite.Run(t, new(BufferedReaderTestSuite))
+}

+ 2 - 9
imagetype/imagetype.go

@@ -1,3 +1,5 @@
+// Code generated by gen_imagetype.go; DO NOT EDIT.
+
 package imagetype
 
 import (
@@ -74,15 +76,6 @@ var (
 	}
 )
 
-func ByMime(mime string) Type {
-	for k, v := range mimes {
-		if v == mime {
-			return k
-		}
-	}
-	return Unknown
-}
-
 func (it Type) String() string {
 	// JPEG has two names, we should use only the full one
 	if it == JPEG {

+ 191 - 0
imagetype_new/defs.go

@@ -0,0 +1,191 @@
+package imagetype_new
+
+var (
+	JPEG = RegisterType(&TypeDesc{
+		String:                "jpeg",
+		Ext:                   ".jpg",
+		Mime:                  "image/jpeg",
+		IsVector:              false,
+		SupportsAlpha:         false,
+		SupportsColourProfile: true,
+		SupportsQuality:       true,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     false,
+	})
+
+	JXL = RegisterType(&TypeDesc{
+		String:                "jxl",
+		Ext:                   ".jxl",
+		Mime:                  "image/jxl",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: true,
+		SupportsQuality:       true,
+		SupportsAnimationLoad: true,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     false,
+	})
+
+	PNG = RegisterType(&TypeDesc{
+		String:                "png",
+		Ext:                   ".png",
+		Mime:                  "image/png",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: true,
+		SupportsQuality:       false,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     false,
+	})
+
+	WEBP = RegisterType(&TypeDesc{
+		String:                "webp",
+		Ext:                   ".webp",
+		Mime:                  "image/webp",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: true,
+		SupportsQuality:       true,
+		SupportsAnimationLoad: true,
+		SupportsAnimationSave: true,
+		SupportsThumbnail:     false,
+	})
+
+	GIF = RegisterType(&TypeDesc{
+		String:                "gif",
+		Ext:                   ".gif",
+		Mime:                  "image/gif",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: false,
+		SupportsQuality:       false,
+		SupportsAnimationLoad: true,
+		SupportsAnimationSave: true,
+		SupportsThumbnail:     false,
+	})
+
+	ICO = RegisterType(&TypeDesc{
+		String:                "ico",
+		Ext:                   ".ico",
+		Mime:                  "image/x-icon",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: false,
+		SupportsQuality:       false,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     false,
+	})
+
+	SVG = RegisterType(&TypeDesc{
+		String:                "svg",
+		Ext:                   ".svg",
+		Mime:                  "image/svg+xml",
+		IsVector:              true,
+		SupportsAlpha:         true,
+		SupportsColourProfile: false,
+		SupportsQuality:       false,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     false,
+	})
+
+	HEIC = RegisterType(&TypeDesc{
+		String:                "heic",
+		Ext:                   ".heic",
+		Mime:                  "image/heif",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: true,
+		SupportsQuality:       true,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     true,
+	})
+
+	AVIF = RegisterType(&TypeDesc{
+		String:                "avif",
+		Ext:                   ".avif",
+		Mime:                  "image/avif",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: true,
+		SupportsQuality:       true,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     true,
+	})
+
+	BMP = RegisterType(&TypeDesc{
+		String:                "bmp",
+		Ext:                   ".bmp",
+		Mime:                  "image/bmp",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: false,
+		SupportsQuality:       false,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     false,
+	})
+
+	TIFF = RegisterType(&TypeDesc{
+		String:                "tiff",
+		Ext:                   ".tiff",
+		Mime:                  "image/tiff",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: false,
+		SupportsQuality:       true,
+		SupportsAnimationLoad: false,
+		SupportsAnimationSave: false,
+		SupportsThumbnail:     false,
+	})
+)
+
+// init registers default magic bytes for common image formats
+func init() {
+	// NOTE: we cannot be 100% sure of image type until we fully decode it. This is especially true
+	// for "naked" jxl (0xff 0x0a). There is no other way to ensure this is a JXL file, except to fully
+	// decode it. Two bytes are too few to reliably identify the format. The same applies to ICO.
+
+	// JPEG magic bytes
+	RegisterMagicBytes(JPEG, []byte("\xff\xd8"))
+
+	// JXL magic bytes
+	RegisterMagicBytes(JXL, []byte{0xff, 0x0a})                                                             // JXL codestream (can't use string due to 0x0a)
+	RegisterMagicBytes(JXL, []byte{0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A}) // JXL container (has null bytes)
+
+	// PNG magic bytes
+	RegisterMagicBytes(PNG, []byte("\x89PNG\r\n\x1a\n"))
+
+	// WEBP magic bytes (RIFF container with WEBP fourcc) - using wildcard for size
+	RegisterMagicBytes(WEBP, []byte("RIFF????WEBP"))
+
+	// GIF magic bytes
+	RegisterMagicBytes(GIF, []byte("GIF8?a"))
+
+	// ICO magic bytes
+	RegisterMagicBytes(ICO, []byte{0, 0, 1, 0}) // ICO (has null bytes)
+
+	// HEIC/HEIF magic bytes with wildcards for size
+	RegisterMagicBytes(HEIC, []byte("????ftypheic"),
+		[]byte("????ftypheix"),
+		[]byte("????ftyphevc"),
+		[]byte("????ftypheim"),
+		[]byte("????ftypheis"),
+		[]byte("????ftyphevm"),
+		[]byte("????ftyphevs"),
+		[]byte("????ftypmif1"))
+
+	// AVIF magic bytes
+	RegisterMagicBytes(AVIF, []byte("????ftypavif"))
+
+	// BMP magic bytes
+	RegisterMagicBytes(BMP, []byte("BM"))
+
+	// TIFF magic bytes (little-endian and big-endian)
+	RegisterMagicBytes(TIFF, []byte("II*\x00"), []byte("MM\x00*")) // Big-Endian, Little-endian
+}

+ 23 - 0
imagetype_new/errors.go

@@ -0,0 +1,23 @@
+package imagetype_new
+
+import (
+	"net/http"
+
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+)
+
+type (
+	UnknownFormatError struct{}
+)
+
+func newUnknownFormatError() error {
+	return ierrors.Wrap(
+		UnknownFormatError{},
+		1,
+		ierrors.WithStatusCode(http.StatusUnprocessableEntity),
+		ierrors.WithPublicMessage("Invalid source image"),
+		ierrors.WithShouldReport(false),
+	)
+}
+
+func (e UnknownFormatError) Error() string { return "Source image type not supported" }

+ 143 - 0
imagetype_new/registry.go

@@ -0,0 +1,143 @@
+package imagetype_new
+
+import (
+	"io"
+
+	"github.com/imgproxy/imgproxy/v3/bufreader"
+)
+
+const (
+	// maxDetectionLimit is maximum bytes detectors allowed to read from the source
+	maxDetectionLimit = 32 * 1024
+)
+
+// TypeDesc is used to store metadata about an image type.
+// It represents the minimal information needed to make imgproxy to
+// work with the type.
+type TypeDesc struct {
+	String                string
+	Ext                   string
+	Mime                  string
+	IsVector              bool
+	SupportsAlpha         bool
+	SupportsColourProfile bool
+	SupportsQuality       bool
+	SupportsAnimationLoad bool
+	SupportsAnimationSave bool
+	SupportsThumbnail     bool
+}
+
+// DetectFunc is a function that detects the image type from byte data
+type DetectFunc func(r bufreader.ReadPeeker) (Type, error)
+
+// Registry holds the type registry
+type Registry struct {
+	detectors []DetectFunc
+	types     []*TypeDesc
+}
+
+// globalRegistry is the default registry instance
+var globalRegistry = &Registry{}
+
+// RegisterType registers a new image type in the global registry.
+// It panics if the type already exists (i.e., if a TypeDesc is already registered for this Type).
+func RegisterType(desc *TypeDesc) Type {
+	return globalRegistry.RegisterType(desc)
+}
+
+// GetTypeDesc returns the TypeDesc for the given Type.
+// Returns nil if the type is not registered.
+func GetTypeDesc(t Type) *TypeDesc {
+	return globalRegistry.GetTypeDesc(t)
+}
+
+// RegisterType registers a new image type in this registry.
+// It panics if the type already exists (i.e., if a TypeDesc is already registered for this Type).
+func (r *Registry) RegisterType(desc *TypeDesc) Type {
+	r.types = append(r.types, desc)
+	return Type(len(r.types)) // 0 is unknown
+}
+
+// GetTypeDesc returns the TypeDesc for the given Type.
+// Returns nil if the type is not registered.
+func (r *Registry) GetTypeDesc(t Type) *TypeDesc {
+	if t <= 0 { // This would be "default" type
+		return nil
+	}
+
+	if int(t-1) >= len(r.types) {
+		return nil
+	}
+
+	return r.types[t-1]
+}
+
+// RegisterDetector registers a custom detector function
+// Detectors are tried in the order they were registered
+func RegisterDetector(detector DetectFunc) {
+	globalRegistry.RegisterDetector(detector)
+}
+
+// RegisterMagicBytes registers magic bytes for a specific image type
+// Magic byte detectors are always tried before custom detectors
+func RegisterMagicBytes(typ Type, signature ...[]byte) {
+	globalRegistry.RegisterMagicBytes(typ, signature...)
+}
+
+// Detect attempts to detect the image type from a reader.
+// It first tries magic byte detection, then custom detectors in registration order
+func Detect(r io.Reader) (Type, error) {
+	return globalRegistry.Detect(r)
+}
+
+// RegisterDetector registers a custom detector function on this registry instance
+func (r *Registry) RegisterDetector(detector DetectFunc) {
+	r.detectors = append(r.detectors, detector)
+}
+
+// RegisterMagicBytes registers magic bytes for a specific image type on this registry instance
+func (r *Registry) RegisterMagicBytes(typ Type, signature ...[]byte) {
+	r.detectors = append(r.detectors, func(r bufreader.ReadPeeker) (Type, error) {
+		for _, sig := range signature {
+			b, err := r.Peek(len(sig))
+			if err != nil {
+				return Unknown, err
+			}
+
+			if hasMagicBytes(b, sig) {
+				return typ, nil
+			}
+		}
+
+		return Unknown, nil
+	})
+}
+
+// Detect runs image format detection
+func (r *Registry) Detect(re io.Reader) (Type, error) {
+	br := bufreader.New(io.LimitReader(re, maxDetectionLimit))
+
+	for _, fn := range globalRegistry.detectors {
+		br.Rewind()
+		if typ, err := fn(br); err == nil && typ != Unknown {
+			return typ, nil
+		}
+	}
+
+	return Unknown, newUnknownFormatError()
+}
+
+// hasMagicBytes checks if the data matches a magic byte signature
+// Supports '?' characters in signature which match any byte
+func hasMagicBytes(data []byte, magic []byte) bool {
+	if len(data) < len(magic) {
+		return false
+	}
+
+	for i, c := range magic {
+		if c != data[i] && c != '?' {
+			return false
+		}
+	}
+	return true
+}

+ 178 - 0
imagetype_new/registry_test.go

@@ -0,0 +1,178 @@
+package imagetype_new
+
+import (
+	"testing"
+
+	"github.com/imgproxy/imgproxy/v3/bufreader"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRegisterType(t *testing.T) {
+	// Create a separate registry for testing to avoid conflicts with global registry
+	testRegistry := &Registry{}
+
+	// Register a custom type
+	customDesc := &TypeDesc{
+		String:                "custom",
+		Ext:                   ".custom",
+		Mime:                  "image/custom",
+		IsVector:              false,
+		SupportsAlpha:         true,
+		SupportsColourProfile: true,
+	}
+
+	customType := testRegistry.RegisterType(customDesc)
+
+	// Verify the type is now registered
+	result := testRegistry.GetTypeDesc(customType)
+	require.NotNil(t, result)
+	require.Equal(t, customDesc.String, result.String)
+	require.Equal(t, customDesc.Ext, result.Ext)
+	require.Equal(t, customDesc.Mime, result.Mime)
+	require.Equal(t, customDesc.IsVector, result.IsVector)
+	require.Equal(t, customDesc.SupportsAlpha, result.SupportsAlpha)
+	require.Equal(t, customDesc.SupportsColourProfile, result.SupportsColourProfile)
+}
+
+func TestTypeProperties(t *testing.T) {
+	// Test that Type methods use TypeDesc fields correctly
+	tests := []struct {
+		name                string
+		typ                 Type
+		expectVector        bool
+		expectAlpha         bool
+		expectColourProfile bool
+		expectQuality       bool
+		expectAnimationLoad bool
+		expectAnimationSave bool
+		expectThumbnail     bool
+	}{
+		{
+			name:                "JPEG",
+			typ:                 JPEG,
+			expectVector:        false,
+			expectAlpha:         false,
+			expectColourProfile: true,
+			expectQuality:       true,
+			expectAnimationLoad: false,
+			expectAnimationSave: false,
+			expectThumbnail:     false,
+		},
+		{
+			name:                "PNG",
+			typ:                 PNG,
+			expectVector:        false,
+			expectAlpha:         true,
+			expectColourProfile: true,
+			expectQuality:       false,
+			expectAnimationLoad: false,
+			expectAnimationSave: false,
+			expectThumbnail:     false,
+		},
+		{
+			name:                "WEBP",
+			typ:                 WEBP,
+			expectVector:        false,
+			expectAlpha:         true,
+			expectColourProfile: true,
+			expectQuality:       true,
+			expectAnimationLoad: true,
+			expectAnimationSave: true,
+			expectThumbnail:     false,
+		},
+		{
+			name:                "SVG",
+			typ:                 SVG,
+			expectVector:        true,
+			expectAlpha:         true,
+			expectColourProfile: false,
+			expectQuality:       false,
+			expectAnimationLoad: false,
+			expectAnimationSave: false,
+			expectThumbnail:     false,
+		},
+		{
+			name:                "GIF",
+			typ:                 GIF,
+			expectVector:        false,
+			expectAlpha:         true,
+			expectColourProfile: false,
+			expectQuality:       false,
+			expectAnimationLoad: true,
+			expectAnimationSave: true,
+			expectThumbnail:     false,
+		},
+		{
+			name:                "HEIC",
+			typ:                 HEIC,
+			expectVector:        false,
+			expectAlpha:         true,
+			expectColourProfile: true,
+			expectQuality:       true,
+			expectAnimationLoad: false,
+			expectAnimationSave: false,
+			expectThumbnail:     true,
+		},
+		{
+			name:                "AVIF",
+			typ:                 AVIF,
+			expectVector:        false,
+			expectAlpha:         true,
+			expectColourProfile: true,
+			expectQuality:       true,
+			expectAnimationLoad: false,
+			expectAnimationSave: false,
+			expectThumbnail:     true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require.Equal(t, tt.expectVector, tt.typ.IsVector())
+			require.Equal(t, tt.expectAlpha, tt.typ.SupportsAlpha())
+			require.Equal(t, tt.expectColourProfile, tt.typ.SupportsColourProfile())
+			require.Equal(t, tt.expectQuality, tt.typ.SupportsQuality())
+			require.Equal(t, tt.expectAnimationLoad, tt.typ.SupportsAnimationLoad())
+			require.Equal(t, tt.expectAnimationSave, tt.typ.SupportsAnimationSave())
+			require.Equal(t, tt.expectThumbnail, tt.typ.SupportsThumbnail())
+		})
+	}
+}
+
+func TestRegisterDetector(t *testing.T) {
+	// Create a test registry to avoid interfering with global state
+	testRegistry := &Registry{}
+
+	// Create a test detector function
+	testDetector := func(r bufreader.ReadPeeker) (Type, error) {
+		b, err := r.Peek(2)
+		if err != nil {
+			return Unknown, err
+		}
+		if len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8 {
+			return JPEG, nil
+		}
+		return Unknown, newUnknownFormatError()
+	}
+
+	// Register the detector using the method
+	testRegistry.RegisterDetector(testDetector)
+
+	// Verify the detector is registered
+	require.Len(t, testRegistry.detectors, 1)
+	require.NotNil(t, testRegistry.detectors[0])
+}
+
+func TestRegisterMagicBytes(t *testing.T) {
+	// Create a test registry to avoid interfering with global state
+	testRegistry := &Registry{}
+
+	require.Empty(t, testRegistry.detectors)
+
+	// Register magic bytes for JPEG using the method
+	jpegMagic := []byte{0xFF, 0xD8}
+	testRegistry.RegisterMagicBytes(JPEG, jpegMagic)
+
+	// Verify the magic bytes are registered
+	require.Len(t, testRegistry.detectors, 1)
+}

+ 34 - 0
imagetype_new/svg.go

@@ -0,0 +1,34 @@
+package imagetype_new
+
+import (
+	"strings"
+
+	"github.com/imgproxy/imgproxy/v3/bufreader"
+
+	"github.com/tdewolff/parse/v2"
+	"github.com/tdewolff/parse/v2/xml"
+)
+
+func init() {
+	// Register SVG detector (needs at least 1000 bytes to reliably detect SVG)
+	RegisterDetector(IsSVG)
+}
+
+func IsSVG(r bufreader.ReadPeeker) (Type, error) {
+	l := xml.NewLexer(parse.NewInput(r))
+
+	for {
+		tt, _ := l.Next()
+
+		switch tt {
+		case xml.ErrorToken:
+			return Unknown, nil
+
+		case xml.StartTagToken:
+			tag := strings.ToLower(string(l.Text()))
+			if tag == "svg" || tag == "svg:svg" {
+				return SVG, nil
+			}
+		}
+	}
+}

+ 121 - 0
imagetype_new/type.go

@@ -0,0 +1,121 @@
+package imagetype_new
+
+import (
+	"fmt"
+)
+
+// Type represents an image type
+type (
+	// Type represents an image type.
+	Type int
+)
+
+// Supported image types
+var (
+	// Unknown is a reserved type, it has index 0. We guarantee that index 0 won't be used
+	// for any other type. This way, Unknown is a zero value for Type.
+	Unknown Type = 0
+)
+
+// Mime returns the MIME type for the image type.
+func (t Type) Mime() string {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.Mime
+	}
+
+	return "application/octet-stream"
+}
+
+// String returns the string representation of the image type.
+func (t Type) String() string {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.String
+	}
+
+	return ""
+}
+
+// Ext returns the file extension for the image type.
+func (t Type) Ext() string {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.Ext
+	}
+
+	return ""
+}
+
+// MarshalJSON implements the json.Marshaler interface for Type.
+func (t Type) MarshalJSON() ([]byte, error) {
+	s := t.String()
+	if s == "" {
+		return []byte("null"), nil
+	}
+
+	return fmt.Appendf(nil, "%q", s), nil
+}
+
+// IsVector checks if the image type is a vector format.
+func (t Type) IsVector() bool {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.IsVector
+	}
+	return false
+}
+
+// SupportsAlpha checks if the image type supports alpha transparency.
+func (t Type) SupportsAlpha() bool {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.SupportsAlpha
+	}
+	return true
+}
+
+// SupportsAnimationLoad checks if the image type supports animation.
+func (t Type) SupportsAnimationLoad() bool {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.SupportsAnimationLoad
+	}
+	return false
+}
+
+// SupportsAnimationSave checks if the image type supports saving animations.
+func (t Type) SupportsAnimationSave() bool {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.SupportsAnimationSave
+	}
+	return false
+}
+
+// SupportsColourProfile checks if the image type supports colour profiles.
+func (t Type) SupportsColourProfile() bool {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.SupportsColourProfile
+	}
+	return false
+}
+
+// SupportsQuality checks if the image type supports quality adjustments.
+func (t Type) SupportsQuality() bool {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.SupportsQuality
+	}
+	return false
+}
+
+// SupportsThumbnail checks if the image type supports thumbnails.
+func (t Type) SupportsThumbnail() bool {
+	desc := GetTypeDesc(t)
+	if desc != nil {
+		return desc.SupportsThumbnail
+	}
+	return false
+}

+ 59 - 0
imagetype_new/type_test.go

@@ -0,0 +1,59 @@
+package imagetype_new
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestDefaultTypesRegistered(t *testing.T) {
+	// Test that all default types are properly registered by init()
+	defaultTypes := []Type{
+		JPEG, JXL, PNG, WEBP, GIF, ICO, SVG, HEIC, AVIF, BMP, TIFF,
+	}
+
+	for _, typ := range defaultTypes {
+		t.Run(typ.String(), func(t *testing.T) {
+			desc := GetTypeDesc(typ)
+			require.NotNil(t, desc)
+
+			// Verify that the description has non-empty fields
+			require.NotEmpty(t, desc.String)
+			require.NotEmpty(t, desc.Ext)
+			require.NotEqual(t, "application/octet-stream", desc.Mime)
+		})
+	}
+}
+
+func TestDetect(t *testing.T) {
+	tests := []struct {
+		name string
+		file string
+		want Type
+	}{
+		{"JPEG", "../testdata/test-images/jpg/jpg.jpg", JPEG},
+		{"JXL", "../testdata/test-images/jxl/jxl.jxl", JXL},
+		{"PNG", "../testdata/test-images/png/png.png", PNG},
+		{"WEBP", "../testdata/test-images/webp/webp.webp", WEBP},
+		{"GIF", "../testdata/test-images/gif/gif.gif", GIF},
+		{"ICO", "../testdata/test-images/ico/png-256x256.ico", ICO},
+		{"SVG", "../testdata/test-images/svg/svg.svg", SVG},
+		{"HEIC", "../testdata/test-images/heif/heif.heif", HEIC},
+		{"BMP", "../testdata/test-images/bmp/24-bpp.bmp", BMP},
+		{"TIFF", "../testdata/test-images/tiff/tiff.tiff", TIFF},
+		{"SVG", "../testdata/test-images/svg/svg.svg", SVG},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			f, err := os.Open(tt.file)
+			require.NoError(t, err)
+			defer f.Close()
+
+			got, err := Detect(f)
+			require.NoError(t, err)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}

+ 1 - 1
server.go

@@ -41,7 +41,7 @@ func buildRouter() *router.Router {
 func startServer(cancel context.CancelFunc) (*http.Server, error) {
 	l, err := reuseport.Listen(config.Network, config.Bind)
 	if err != nil {
-		return nil, fmt.Errorf("Can't start server: %s", err)
+		return nil, fmt.Errorf("can't start server: %s", err)
 	}
 
 	if config.MaxClients > 0 {