mirror of
https://github.com/talgo-cloud/bimg.git
synced 2026-03-07 21:48:13 -08:00
refactor(#47): minor refactors, code normalization and test coverage
This commit is contained in:
parent
6edd96ee41
commit
ed4faadba6
10 changed files with 309 additions and 92 deletions
111
README.md
111
README.md
|
|
@ -238,7 +238,7 @@ bimg.Write("new.jpg", newImage)
|
|||
|
||||
Run the process passing the `DEBUG` environment variable
|
||||
```
|
||||
DEBUG=* ./app
|
||||
DEBUG=bimg ./app
|
||||
```
|
||||
|
||||
Enable libvips traces (note that a lot of data will be written in stdout):
|
||||
|
|
@ -248,6 +248,12 @@ VIPS_TRACE=1 ./app
|
|||
|
||||
### Programmatic API
|
||||
|
||||
#### func ColourspaceIsSupported
|
||||
|
||||
```go
|
||||
func ColourspaceIsSupported(buf []byte) (bool, error)
|
||||
```
|
||||
Check in the image colourspace is supported by libvips
|
||||
|
||||
#### func DetermineImageTypeName
|
||||
|
||||
|
|
@ -295,7 +301,7 @@ func Resize(buf []byte, o Options) ([]byte, error)
|
|||
```go
|
||||
func Shutdown()
|
||||
```
|
||||
Thread-safe function to shutdown libvips. You could call this to drop caches as
|
||||
Thread-safe function to shutdown libvips. You can call this to drop caches as
|
||||
well. If libvips was already initialized, the function is no-op
|
||||
|
||||
#### func VipsDebugInfo
|
||||
|
|
@ -383,6 +389,20 @@ func NewImage(buf []byte) *Image
|
|||
```
|
||||
Creates a new image
|
||||
|
||||
#### func (*Image) Colourspace
|
||||
|
||||
```go
|
||||
func (i *Image) Colourspace(c Interpretation) ([]byte, error)
|
||||
```
|
||||
Colour space conversion
|
||||
|
||||
#### func (*Image) ColourspaceIsSupported
|
||||
|
||||
```go
|
||||
func (i *Image) ColourspaceIsSupported() (bool, error)
|
||||
```
|
||||
Check if the current image has a valid colourspace
|
||||
|
||||
#### func (*Image) Convert
|
||||
|
||||
```go
|
||||
|
|
@ -453,6 +473,14 @@ func (i *Image) Image() []byte
|
|||
```
|
||||
Get image buffer
|
||||
|
||||
#### func (*Image) Interpretation
|
||||
|
||||
```go
|
||||
func (i *Image) Interpretation() (Interpretation, error)
|
||||
```
|
||||
Get the image interpretation type See:
|
||||
http://www.vips.ecs.soton.ac.uk/supported/current/doc/html/libvips/VipsImage.html#VipsInterpretation
|
||||
|
||||
#### func (*Image) Metadata
|
||||
|
||||
```go
|
||||
|
|
@ -533,6 +561,7 @@ type ImageMetadata struct {
|
|||
Profile bool
|
||||
Type string
|
||||
Space string
|
||||
Colourspace string
|
||||
Size ImageSize
|
||||
}
|
||||
```
|
||||
|
|
@ -609,33 +638,65 @@ const (
|
|||
func (i Interpolator) String() string
|
||||
```
|
||||
|
||||
#### type Interpretation
|
||||
|
||||
```go
|
||||
type Interpretation int
|
||||
```
|
||||
|
||||
Image interpretation type See:
|
||||
http://www.vips.ecs.soton.ac.uk/supported/current/doc/html/libvips/VipsImage.html#VipsInterpretation
|
||||
|
||||
```go
|
||||
const (
|
||||
INTERPRETATION_ERROR Interpretation = C.VIPS_INTERPRETATION_ERROR
|
||||
INTERPRETATION_MULTIBAND Interpretation = C.VIPS_INTERPRETATION_MULTIBAND
|
||||
INTERPRETATION_B_W Interpretation = C.VIPS_INTERPRETATION_B_W
|
||||
INTERPRETATION_CMYK Interpretation = C.VIPS_INTERPRETATION_CMYK
|
||||
INTERPRETATION_RGB Interpretation = C.VIPS_INTERPRETATION_RGB
|
||||
INTERPRETATION_sRGB Interpretation = C.VIPS_INTERPRETATION_sRGB
|
||||
INTERPRETATION_RGB16 Interpretation = C.VIPS_INTERPRETATION_RGB16
|
||||
INTERPRETATION_GREY16 Interpretation = C.VIPS_INTERPRETATION_GREY16
|
||||
INTERPRETATION_scRGB Interpretation = C.VIPS_INTERPRETATION_scRGB
|
||||
)
|
||||
```
|
||||
|
||||
#### func ImageInterpretation
|
||||
|
||||
```go
|
||||
func ImageInterpretation(buf []byte) (Interpretation, error)
|
||||
```
|
||||
Get the image interpretation type See:
|
||||
http://www.vips.ecs.soton.ac.uk/supported/current/doc/html/libvips/VipsImage.html#VipsInterpretation
|
||||
|
||||
#### type Options
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
Height int
|
||||
Width int
|
||||
AreaHeight int
|
||||
AreaWidth int
|
||||
Top int
|
||||
Left int
|
||||
Extend int
|
||||
Quality int
|
||||
Compression int
|
||||
Zoom int
|
||||
Crop bool
|
||||
Enlarge bool
|
||||
Embed bool
|
||||
Flip bool
|
||||
Flop bool
|
||||
NoAutoRotate bool
|
||||
NoProfile bool
|
||||
Interlace bool
|
||||
Rotate Angle
|
||||
Gravity Gravity
|
||||
Watermark Watermark
|
||||
Type ImageType
|
||||
Interpolator Interpolator
|
||||
Height int
|
||||
Width int
|
||||
AreaHeight int
|
||||
AreaWidth int
|
||||
Top int
|
||||
Left int
|
||||
Extend int
|
||||
Quality int
|
||||
Compression int
|
||||
Zoom int
|
||||
Crop bool
|
||||
Enlarge bool
|
||||
Embed bool
|
||||
Flip bool
|
||||
Flop bool
|
||||
NoAutoRotate bool
|
||||
NoProfile bool
|
||||
Interlace bool
|
||||
Rotate Angle
|
||||
Gravity Gravity
|
||||
Watermark Watermark
|
||||
Type ImageType
|
||||
Interpolator Interpolator
|
||||
Interpretation Interpretation
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
11
image.go
11
image.go
|
|
@ -155,6 +155,17 @@ func (i *Image) Metadata() (ImageMetadata, error) {
|
|||
return Metadata(i.buffer)
|
||||
}
|
||||
|
||||
// Get the image interpretation type
|
||||
// See: http://www.vips.ecs.soton.ac.uk/supported/current/doc/html/libvips/VipsImage.html#VipsInterpretation
|
||||
func (i *Image) Interpretation() (Interpretation, error) {
|
||||
return ImageInterpretation(i.buffer)
|
||||
}
|
||||
|
||||
// Check if the current image has a valid colourspace
|
||||
func (i *Image) ColourspaceIsSupported() (bool, error) {
|
||||
return ColourspaceIsSupported(i.buffer)
|
||||
}
|
||||
|
||||
// Get image type format (jpeg, png, webp, tiff)
|
||||
func (i *Image) Type() string {
|
||||
return DetermineImageTypeName(i.buffer)
|
||||
|
|
|
|||
|
|
@ -262,12 +262,36 @@ func TestImageMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImageColourspaceBW(t *testing.T) {
|
||||
buf, err := initImage("test.jpg").Colourspace(B_W)
|
||||
func TestInterpretation(t *testing.T) {
|
||||
interpretation, err := initImage("test.jpg").Interpretation()
|
||||
if err != nil {
|
||||
t.Errorf("Cannot process the image: %#v", err)
|
||||
}
|
||||
Write("fixtures/test_image_colourspace_b_w.jpg", buf)
|
||||
if interpretation != INTERPRETATION_sRGB {
|
||||
t.Errorf("Invalid interpretation: %d", interpretation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageColourspaceBW(t *testing.T) {
|
||||
buf, err := initImage("test.jpg").Colourspace(INTERPRETATION_B_W)
|
||||
if err != nil {
|
||||
t.Errorf("Cannot process the image: %#v", err)
|
||||
}
|
||||
|
||||
interpretation, err := ImageInterpretation(buf)
|
||||
if interpretation != INTERPRETATION_B_W {
|
||||
t.Errorf("Invalid colourspace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageColourspaceIsSupported(t *testing.T) {
|
||||
supported, err := initImage("test.jpg").ColourspaceIsSupported()
|
||||
if err != nil {
|
||||
t.Errorf("Cannot process the image: %#v", err)
|
||||
}
|
||||
if supported != true {
|
||||
t.Errorf("Non-supported colourspace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFluentInterface(t *testing.T) {
|
||||
|
|
|
|||
12
metadata.go
12
metadata.go
|
|
@ -18,6 +18,7 @@ type ImageMetadata struct {
|
|||
Profile bool
|
||||
Type string
|
||||
Space string
|
||||
Colourspace string
|
||||
Size ImageSize
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +35,17 @@ func Size(buf []byte) (ImageSize, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// Check in the image colourspace is supported by libvips
|
||||
func ColourspaceIsSupported(buf []byte) (bool, error) {
|
||||
return vipsColourspaceIsSupportedBuffer(buf)
|
||||
}
|
||||
|
||||
// Get the image interpretation type
|
||||
// See: http://www.vips.ecs.soton.ac.uk/supported/current/doc/html/libvips/VipsImage.html#VipsInterpretation
|
||||
func ImageInterpretation(buf []byte) (Interpretation, error) {
|
||||
return vipsInterpretationBuffer(buf)
|
||||
}
|
||||
|
||||
// Extract the image metadata (size, type, alpha channel, profile, EXIF orientation...)
|
||||
func Metadata(buf []byte) (ImageMetadata, error) {
|
||||
defer C.vips_thread_shutdown()
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ func TestMetadata(t *testing.T) {
|
|||
profile bool
|
||||
space string
|
||||
}{
|
||||
{"test.jpg", "jpeg", 0, false, false, "bicubic"},
|
||||
{"test.png", "png", 0, true, false, "bicubic"},
|
||||
{"test.webp", "webp", 0, false, false, "bicubic"},
|
||||
{"test.jpg", "jpeg", 0, false, false, "srgb"},
|
||||
{"test.png", "png", 0, true, false, "srgb"},
|
||||
{"test.webp", "webp", 0, false, false, "srgb"},
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
|
|
@ -61,6 +61,58 @@ func TestMetadata(t *testing.T) {
|
|||
if metadata.Profile != file.profile {
|
||||
t.Fatalf("Unexpected image profile: %s != %s", metadata.Profile, file.profile)
|
||||
}
|
||||
if metadata.Space != file.space {
|
||||
t.Fatalf("Unexpected image profile: %s != %s", metadata.Profile, file.profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageInterpretation(t *testing.T) {
|
||||
files := []struct {
|
||||
name string
|
||||
interpretation Interpretation
|
||||
}{
|
||||
{"test.jpg", INTERPRETATION_sRGB},
|
||||
{"test.png", INTERPRETATION_sRGB},
|
||||
{"test.webp", INTERPRETATION_sRGB},
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
interpretation, err := ImageInterpretation(readFile(file.name))
|
||||
if err != nil {
|
||||
t.Fatalf("Cannot read the image: %s -> %s", file.name, err)
|
||||
}
|
||||
if interpretation != file.interpretation {
|
||||
t.Fatalf("Unexpected image interpretation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestColourspaceIsSupported(t *testing.T) {
|
||||
files := []struct {
|
||||
name string
|
||||
}{
|
||||
{"test.jpg"},
|
||||
{"test.png"},
|
||||
{"test.webp"},
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
supported, err := ColourspaceIsSupported(readFile(file.name))
|
||||
if err != nil {
|
||||
t.Fatalf("Cannot read the image: %s -> %s", file.name, err)
|
||||
}
|
||||
if supported != true {
|
||||
t.Fatalf("Unsupported image colourspace")
|
||||
}
|
||||
}
|
||||
|
||||
supported, err := initImage("test.jpg").ColourspaceIsSupported()
|
||||
if err != nil {
|
||||
t.Errorf("Cannot process the image: %#v", err)
|
||||
}
|
||||
if supported != true {
|
||||
t.Errorf("Non-supported colourspace")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
66
options.go
66
options.go
|
|
@ -55,18 +55,20 @@ const (
|
|||
VERTICAL Direction = C.VIPS_DIRECTION_VERTICAL
|
||||
)
|
||||
|
||||
// Image interpretation type
|
||||
// See: http://www.vips.ecs.soton.ac.uk/supported/current/doc/html/libvips/VipsImage.html#VipsInterpretation
|
||||
type Interpretation int
|
||||
|
||||
const (
|
||||
ERROR Interpretation = C.VIPS_INTERPRETATION_ERROR
|
||||
MULTIBAND Interpretation = C.VIPS_INTERPRETATION_MULTIBAND
|
||||
B_W Interpretation = C.VIPS_INTERPRETATION_B_W
|
||||
CMYK Interpretation = C.VIPS_INTERPRETATION_CMYK
|
||||
RGB Interpretation = C.VIPS_INTERPRETATION_RGB
|
||||
sRGB Interpretation = C.VIPS_INTERPRETATION_sRGB
|
||||
RGB16 Interpretation = C.VIPS_INTERPRETATION_RGB16
|
||||
GREY16 Interpretation = C.VIPS_INTERPRETATION_GREY16
|
||||
scRGB Interpretation = C.VIPS_INTERPRETATION_scRGB
|
||||
INTERPRETATION_ERROR Interpretation = C.VIPS_INTERPRETATION_ERROR
|
||||
INTERPRETATION_MULTIBAND Interpretation = C.VIPS_INTERPRETATION_MULTIBAND
|
||||
INTERPRETATION_B_W Interpretation = C.VIPS_INTERPRETATION_B_W
|
||||
INTERPRETATION_CMYK Interpretation = C.VIPS_INTERPRETATION_CMYK
|
||||
INTERPRETATION_RGB Interpretation = C.VIPS_INTERPRETATION_RGB
|
||||
INTERPRETATION_sRGB Interpretation = C.VIPS_INTERPRETATION_sRGB
|
||||
INTERPRETATION_RGB16 Interpretation = C.VIPS_INTERPRETATION_RGB16
|
||||
INTERPRETATION_GREY16 Interpretation = C.VIPS_INTERPRETATION_GREY16
|
||||
INTERPRETATION_scRGB Interpretation = C.VIPS_INTERPRETATION_scRGB
|
||||
)
|
||||
|
||||
const WATERMARK_FONT = "sans 10"
|
||||
|
|
@ -88,28 +90,28 @@ type Watermark struct {
|
|||
}
|
||||
|
||||
type Options struct {
|
||||
Height int
|
||||
Width int
|
||||
AreaHeight int
|
||||
AreaWidth int
|
||||
Top int
|
||||
Left int
|
||||
Extend int
|
||||
Quality int
|
||||
Compression int
|
||||
Zoom int
|
||||
Crop bool
|
||||
Enlarge bool
|
||||
Embed bool
|
||||
Flip bool
|
||||
Flop bool
|
||||
NoAutoRotate bool
|
||||
NoProfile bool
|
||||
Interlace bool
|
||||
Rotate Angle
|
||||
Gravity Gravity
|
||||
Watermark Watermark
|
||||
Type ImageType
|
||||
Interpolator Interpolator
|
||||
Height int
|
||||
Width int
|
||||
AreaHeight int
|
||||
AreaWidth int
|
||||
Top int
|
||||
Left int
|
||||
Extend int
|
||||
Quality int
|
||||
Compression int
|
||||
Zoom int
|
||||
Crop bool
|
||||
Enlarge bool
|
||||
Embed bool
|
||||
Flip bool
|
||||
Flop bool
|
||||
NoAutoRotate bool
|
||||
NoProfile bool
|
||||
Interlace bool
|
||||
Rotate Angle
|
||||
Gravity Gravity
|
||||
Watermark Watermark
|
||||
Type ImageType
|
||||
Interpolator Interpolator
|
||||
Interpretation Interpretation
|
||||
}
|
||||
|
|
|
|||
14
resize.go
14
resize.go
|
|
@ -117,15 +117,15 @@ func Resize(buf []byte, o Options) ([]byte, error) {
|
|||
}
|
||||
|
||||
saveOptions := vipsSaveOptions{
|
||||
Quality: o.Quality,
|
||||
Type: o.Type,
|
||||
Compression: o.Compression,
|
||||
Interlace: o.Interlace,
|
||||
NoProfile: o.NoProfile,
|
||||
Quality: o.Quality,
|
||||
Type: o.Type,
|
||||
Compression: o.Compression,
|
||||
Interlace: o.Interlace,
|
||||
NoProfile: o.NoProfile,
|
||||
Interpretation: o.Interpretation,
|
||||
}
|
||||
|
||||
// Finally save as buffer
|
||||
// Finally get the resultant buffer
|
||||
buf, err = vipsSave(image, saveOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -145,7 +145,7 @@ func applyDefaults(o *Options, imageType ImageType) {
|
|||
o.Type = imageType
|
||||
}
|
||||
if o.Interpretation == 0 {
|
||||
o.Interpretation = sRGB
|
||||
o.Interpretation = INTERPRETATION_sRGB
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
88
vips.go
88
vips.go
|
|
@ -32,11 +32,11 @@ type VipsMemoryInfo struct {
|
|||
}
|
||||
|
||||
type vipsSaveOptions struct {
|
||||
Quality int
|
||||
Compression int
|
||||
Type ImageType
|
||||
Interlace bool
|
||||
NoProfile bool
|
||||
Quality int
|
||||
Compression int
|
||||
Type ImageType
|
||||
Interlace bool
|
||||
NoProfile bool
|
||||
Interpretation Interpretation
|
||||
}
|
||||
|
||||
|
|
@ -224,41 +224,85 @@ func vipsRead(buf []byte) (*C.struct__VipsImage, ImageType, error) {
|
|||
return image, imageType, nil
|
||||
}
|
||||
|
||||
func vipsSave(image *C.struct__VipsImage, o vipsSaveOptions) ([]byte, error) {
|
||||
length := C.size_t(0)
|
||||
err := C.int(0)
|
||||
interlace := C.int(boolToInt(o.Interlace))
|
||||
if o.Interpretation == 0 {
|
||||
o.Interpretation = sRGB
|
||||
func vipsColourspaceIsSupportedBuffer(buf []byte) (bool, error) {
|
||||
image, _, err := vipsRead(buf)
|
||||
defer C.g_object_unref(C.gpointer(image))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
interpretation := C.VipsInterpretation(o.Interpretation)
|
||||
return vipsColourspaceIsSupported(image), nil
|
||||
}
|
||||
|
||||
func vipsColourspaceIsSupported(image *C.struct__VipsImage) bool {
|
||||
return int(C.vips_colourspace_issupported_bridge(image)) == 1
|
||||
}
|
||||
|
||||
func vipsInterpretationBuffer(buf []byte) (Interpretation, error) {
|
||||
image, _, err := vipsRead(buf)
|
||||
defer C.g_object_unref(C.gpointer(image))
|
||||
if err != nil {
|
||||
return Interpretation(-1), err
|
||||
}
|
||||
return vipsInterpretation(image), nil
|
||||
}
|
||||
|
||||
func vipsInterpretation(image *C.struct__VipsImage) Interpretation {
|
||||
return Interpretation(C.vips_image_guess_interpretation_bridge(image))
|
||||
}
|
||||
|
||||
func vipsPreSave(image *C.struct__VipsImage, o *vipsSaveOptions) (*C.struct__VipsImage, error) {
|
||||
// Remove ICC profile metadata
|
||||
if o.NoProfile {
|
||||
C.remove_profile(image)
|
||||
}
|
||||
|
||||
// Force RGB color space
|
||||
var outImage *C.struct__VipsImage
|
||||
C.vips_colourspace_bridge(image, &outImage, interpretation)
|
||||
// Use a default interpretation and cast it to C type
|
||||
if o.Interpretation == 0 {
|
||||
o.Interpretation = INTERPRETATION_sRGB
|
||||
}
|
||||
interpretation := C.VipsInterpretation(o.Interpretation)
|
||||
|
||||
// Apply the proper colour space
|
||||
var outImage *C.struct__VipsImage
|
||||
if vipsColourspaceIsSupported(image) {
|
||||
err := int(C.vips_colourspace_bridge(image, &outImage, interpretation))
|
||||
C.g_object_unref(C.gpointer(image))
|
||||
if err != 0 {
|
||||
return nil, catchVipsError()
|
||||
}
|
||||
image = outImage
|
||||
}
|
||||
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func vipsSave(image *C.struct__VipsImage, o vipsSaveOptions) ([]byte, error) {
|
||||
defer C.g_object_unref(C.gpointer(image))
|
||||
defer C.g_object_unref(C.gpointer(outImage))
|
||||
|
||||
image, err := vipsPreSave(image, &o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
length := C.size_t(0)
|
||||
saveErr := C.int(0)
|
||||
interlace := C.int(boolToInt(o.Interlace))
|
||||
quality := C.int(o.Quality)
|
||||
|
||||
var ptr unsafe.Pointer
|
||||
switch o.Type {
|
||||
case PNG:
|
||||
err = C.vips_pngsave_bridge(outImage, &ptr, &length, 1, C.int(o.Compression), C.int(o.Quality), interlace)
|
||||
break
|
||||
case WEBP:
|
||||
err = C.vips_webpsave_bridge(outImage, &ptr, &length, 1, C.int(o.Quality))
|
||||
saveErr = C.vips_webpsave_bridge(image, &ptr, &length, 1, quality)
|
||||
break
|
||||
case PNG:
|
||||
saveErr = C.vips_pngsave_bridge(image, &ptr, &length, 1, C.int(o.Compression), quality, interlace)
|
||||
break
|
||||
default:
|
||||
err = C.vips_jpegsave_bridge(outImage, &ptr, &length, 1, C.int(o.Quality), interlace)
|
||||
saveErr = C.vips_jpegsave_bridge(image, &ptr, &length, 1, quality, interlace)
|
||||
break
|
||||
}
|
||||
|
||||
if int(err) != 0 {
|
||||
if int(saveErr) != 0 {
|
||||
return nil, catchVipsError()
|
||||
}
|
||||
|
||||
|
|
|
|||
11
vips.h
11
vips.h
|
|
@ -134,6 +134,17 @@ vips_extract_area_bridge(VipsImage *in, VipsImage **out, int left, int top, int
|
|||
return vips_extract_area(in, out, left, top, width, height, NULL);
|
||||
};
|
||||
|
||||
int
|
||||
vips_colourspace_issupported_bridge(VipsImage *in)
|
||||
{
|
||||
return vips_colourspace_issupported(in) ? 1 : 0;
|
||||
};
|
||||
|
||||
VipsInterpretation
|
||||
vips_image_guess_interpretation_bridge(VipsImage *in) {
|
||||
return vips_image_guess_interpretation(in);
|
||||
};
|
||||
|
||||
int
|
||||
vips_colourspace_bridge(VipsImage *in, VipsImage **out, VipsInterpretation space)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue