├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── docker-imagor-magick.yml │ ├── docker.yml │ └── test.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Dockerfile.imagor-magick ├── LICENSE ├── Makefile ├── README.md ├── app.json ├── blob.go ├── blob_test.go ├── cmd └── imagor │ └── main.go ├── config ├── awsconfig │ ├── awsconfig.go │ └── awsconfig_test.go ├── config.go ├── config_test.go ├── fileconfig.go ├── flags.go ├── flags_test.go ├── gcloudconfig │ ├── gcloudconfig.go │ └── gcloudconfig_test.go ├── httpconfig.go ├── option.go ├── option_test.go └── vipsconfig │ ├── vipsconfig.go │ └── vipsconfig_test.go ├── context.go ├── context_test.go ├── errors.go ├── errors_test.go ├── examples ├── from_buffer │ └── main.go ├── from_file │ └── main.go ├── from_memory │ └── main.go ├── from_path │ └── main.go ├── from_reader │ └── main.go ├── server │ └── main.go └── vips │ └── main.go ├── fanoutreader ├── README.md ├── fanout.go └── fanout_test.go ├── go.mod ├── go.sum ├── heroku.yml ├── heroku └── Dockerfile ├── imagor.go ├── imagor_test.go ├── imagorpath ├── README.md ├── generate.go ├── hasher.go ├── hasher_test.go ├── normalize.go ├── params.go ├── params_test.go ├── parse.go └── signer.go ├── loader └── httploader │ ├── httploader.go │ ├── httploader_test.go │ ├── option.go │ ├── timeout_test.go │ └── util.go ├── metrics └── prometheusmetrics │ ├── prometheus.go │ └── prometheus_test.go ├── option.go ├── processor └── vipsprocessor │ ├── context.go │ ├── exif.go │ ├── fallback.go │ ├── filter.go │ ├── option.go │ ├── option_test.go │ ├── process.go │ ├── processor.go │ └── processor_test.go ├── seekstream ├── README.md ├── buffer.go ├── seekstream.go └── seekstream_test.go ├── server ├── handler.go ├── option.go ├── realip.go ├── realip_test.go ├── server.go └── server_test.go ├── storage ├── filestorage │ ├── filestorage.go │ ├── filestorage_test.go │ └── option.go ├── gcloudstorage │ ├── gcloudstorage.go │ ├── gcloudstorage_test.go │ └── option.go └── s3storage │ ├── option.go │ ├── s3storage.go │ └── s3storage_test.go └── testdata ├── 2bands.png ├── Canon_40D.jpg ├── bmp_24.bmp ├── dancing-banana.gif ├── demo1.jpg ├── demo2.jpg ├── demo3.gif ├── demo3.webp ├── demo4.jpg ├── demo5.gif ├── find_trim.png ├── find_trim_alpha.png ├── golden ├── 0.006120x0.008993%3A1.0x1.0 │ └── stretch │ │ └── 100x200 │ │ └── filters%3Abrightness%28-20%29%3Acontrast%2850%29%3Argb%2810%2C-50%2C30%29%3Afill%28black%29 │ │ └── gopher.png ├── 0.1x0.2%3A0.89x0.72 │ └── dancing-banana.gif ├── 0x0 │ └── 40x50 │ │ └── filters%3Afill%28white%29 │ │ └── gopher-front.png ├── 0x100%3A9999x9999 │ └── 300x100 │ │ └── filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29 │ │ └── gopher.png ├── 0x50 │ └── filters%3Afill%28white%29%3Aformat%28jpg%29 │ │ └── Canon_40D.jpg ├── 100x100 │ ├── 10x5 │ │ └── top │ │ │ ├── filters%3Afill%28white%29 │ │ │ └── gopher.png │ │ │ └── filters%3Afill%28yellow%29 │ │ │ └── dancing-banana.gif │ ├── bmp_24.bmp │ ├── dancing-banana.gif │ ├── filters%3Aquality%2870%29%3Aformat%28jpeg%29 │ │ └── gopher.png │ ├── lena_gray.bmp │ └── smart │ │ └── filters%3Aautojpg%28%29 │ │ └── gopher.png ├── 100x200 │ ├── left │ │ ├── bottom │ │ │ ├── dancing-banana.gif │ │ │ └── gopher.png │ │ ├── dancing-banana.gif │ │ ├── filters%3Aorient%2890%29 │ │ │ └── gopher.png │ │ └── gopher.png │ └── right │ │ ├── dancing-banana.gif │ │ ├── gopher.png │ │ └── top │ │ ├── dancing-banana.gif │ │ └── gopher.png ├── 100x30 │ ├── filters%3Afocal%280.1x0%3A0.89x0.72%29 │ │ └── dancing-banana.gif │ └── filters%3Afocal%280.89x0.72%29 │ │ └── dancing-banana.gif ├── 100x300 │ └── filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29 │ │ └── gopher.png ├── 10x20%3A3000x5000 │ └── stretch │ │ └── 100x200 │ │ └── filters%3Abrightness%28-20%29%3Acontrast%2850%29%3Argb%2810%2C-50%2C30%29%3Afill%28black%29 │ │ └── gopher.png ├── 200x-210 │ └── top │ │ ├── filters%3Ablur%281%2C2%29%3Asharpen%281%2C2%29%3Abackground_color%28ff0%29%3Aformat%28jpeg%29%3Aquality%2870%29 │ │ └── gopher.png │ │ └── filters%3Ablur%285%29%3Asharpen%285%29%3Abackground_color%28ffff00%29%3Aformat%28jpeg%29%3Aquality%2870%29 │ │ └── gopher.png ├── 200x0 │ └── 20x20%3A100x20 │ │ └── filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-10%2C-10%2C0%2C50%2C50%29%3Awatermark%28dancing-banana.gif%2C-30%2C10%2C0%2C50%2C50%29 │ │ └── nyan-cat.gif ├── 200x100 │ ├── bottom │ │ ├── dancing-banana.gif │ │ └── gopher.png │ ├── left │ │ └── bottom │ │ │ ├── dancing-banana.gif │ │ │ └── gopher.png │ ├── right │ │ └── top │ │ │ ├── dancing-banana.gif │ │ │ └── gopher.png │ └── top │ │ ├── dancing-banana.gif │ │ └── filters%3Aquality%2870%29%3Aformat%28tiff%29 │ │ └── gopher.png ├── 200x200 │ └── filters%3Aformat%28png%29%3Apalette%28%29%3Abitdepth%284%29%3Acompression%288%29 │ │ └── gopher.png ├── 300x100 │ ├── filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%280.35x0.25%3A0.6x0.3%29 │ │ └── gopher.png │ ├── filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%29%3Afocal%281000x814%29 │ │ └── gopher.png │ ├── filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29 │ │ └── gopher.png │ └── filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%289999x9999%29 │ │ └── gopher.png ├── 300x300 │ └── filters%3Aformat%28jpeg%29%3Afocal%28150%3A150%29 │ │ └── gopher-exif-orientation-cw90.png ├── 30x20%3A100x150 │ └── dancing-banana.gif ├── 50x0 │ └── filters%3Afill%28white%29%3Aformat%28jpg%29 │ │ └── Canon_40D.jpg ├── 50x50%3A0x0 │ └── filters%3Atrim%2850%2Cbottom-right%29 │ │ └── find_trim.png ├── dancing-banana.gif ├── disable-filters │ ├── filters%3Afill%28white%29%3Aformat%28jpeg%29 │ │ └── dancing-banana.gif │ └── fit-in │ │ └── 200x150 │ │ └── filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29 │ │ └── dancing-banana.gif ├── filters%3Abackground_color%28%29%3Around_corner%28%29%3Apadding%28%29%3Arotate%28%29%3Aproportion%28%29%3Aproportion%289999%29%3Aproportion%280.0000000001%29%3Aproportion%28-10%29 │ └── gopher-front.png ├── filters%3Abackground_color%28yellow%29 │ └── demo1.jpg ├── filters%3Afill%28cyan%29%3Around_corner%2860%29 │ └── dancing-banana.gif ├── filters%3Afill%28white%29%3Aformat%28jpeg%29 │ └── dancing-banana.gif ├── filters%3Aformat%28gif%29%3Aquality%2870%29 │ └── gopher-front.png ├── filters%3Aformat%28tiff%29%3Aquality%2870%29 │ └── gopher-front.png ├── filters%3Aformat%28webp%29%3Aquality%2870%29 │ └── gopher-front.png ├── filters%3Amax_bytes%286000%29%3Aformat%28jpg%29%3Afill%28white%29 │ └── gopher.png ├── filters%3Amax_bytes%2860000%29%3Aformat%28jpg%29%3Afill%28white%29 │ └── gopher.png ├── filters%3Amax_frames%283%29 │ └── dancing-banana.gif ├── filters%3Apage%285%29 │ └── dancing-banana.gif ├── filters%3Apage%28999%29 │ └── dancing-banana.gif ├── filters%3Aproportion%28%29%3Aproportion%289999%29%3Aproportion%280.0000000001%29%3Aproportion%28-10%29%3Asharpen%28-1%29 │ └── gopher-front.png ├── filters%3Aproportion%280.1%29 │ └── gopher.png ├── filters%3Aproportion%2810%29 │ └── gopher.png ├── filters%3Aquality%2860%29 │ └── dancing-banana.gif ├── filters%3Astrip_exif%28%29 │ ├── Canon_40D.jpg │ └── dancing-banana.gif ├── filters%3Awatermark%282bands.png%2Crepeat%2Cbottom%2C40%2C25%2C50%29 │ └── demo1.jpg ├── filters%3Awatermark%28demo1.jpg%2Crepeat%2Crepeat%2C40%2C25%2C50%29 │ └── demo1.jpg ├── fit-in │ ├── -180x180 │ │ └── 10x10 │ │ │ └── filters%3Afill%28yellow%29%3Apadding%28white%2C10%2C20%2C30%2C40%29%3Aformat%28jpeg%29 │ │ │ └── gopher.png │ ├── -200x0 │ │ └── filters%3Ahue%28290%29%3Asaturation%28100%29%3Afill%28FFO%29%3Aupscale%28%29 │ │ │ └── gopher.png │ ├── 0x210 │ │ └── filters%3Afill%28yellow%29%3Around_corner%2840%2C60%2Cgreen%29 │ │ │ └── gopher.png │ ├── 0x50 │ │ └── filters%3Afill%28white%29%3Aformat%28jpg%29 │ │ │ └── Canon_40D.jpg │ ├── 100x100 │ │ ├── 10x5 │ │ │ ├── filters%3Afill%28none%29 │ │ │ │ └── gopher.png │ │ │ └── filters%3Afill%28white%29 │ │ │ │ └── gopher.png │ │ ├── demo1.jpg │ │ ├── demo3.webp │ │ ├── filters%3Afill%28auto%29%3Atrim%2850%29 │ │ │ └── find_trim.png │ │ ├── filters%3Afill%28none%29 │ │ │ └── 2bands.png │ │ └── gopher.tiff │ ├── 100x120 │ │ └── 10x5 │ │ │ └── filters%3Afill%28none%29%3Aformat%28png%29 │ │ │ └── demo1.jpg │ ├── 100x150 │ │ └── filters%3Arotate%2890%29%3Afill%28yellow%29 │ │ │ └── dancing-banana.gif │ ├── 100x210 │ │ └── 10x20%3A15x3 │ │ │ └── filters%3Arotate%2890%29%3Afill%28yellow%29 │ │ │ └── gopher-front.png │ ├── 150x200 │ │ └── 10x00%3A10x50 │ │ │ ├── filters%3Afill%28cyan%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cwhite%2C0%2Cmonospace%29 │ │ │ └── dancing-banana.gif │ │ │ └── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cblack%29 │ │ │ └── dancing-banana.gif │ ├── 200x150 │ │ ├── filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29 │ │ │ └── dancing-banana.gif │ │ ├── filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29 │ │ │ └── dancing-banana.gif │ │ ├── filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C30%2C-10%2C0%2C40%2C40%29%3Awatermark%28dancing-banana.gif%2C0%2C10%2C0%2C40%2C40%29 │ │ │ └── nyan-cat.gif │ │ ├── filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2C-20%2C-10%2C0%2C30%2C30%29 │ │ │ └── dancing-banana.gif │ │ └── filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2Crepeat%2Cbottom%2C0%2C30%2C30%29 │ │ │ └── dancing-banana.gif │ ├── 200x210 │ │ └── 20x20 │ │ │ └── filters%3Arotate%2890%29%3Arotate%28270%29%3Arotate%28180%29%3Afill%28blur%29%3Agrayscale%28%29 │ │ │ └── gopher.png │ ├── 300x200 │ │ └── 10x10 │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-0.15%2C0.1%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15%2C-10%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15p%2C10p%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C15%2C10%2C30%2Cblue%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2Cbottom%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cleft%2Ctop%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ └── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cright%2Ccenter%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ ├── 400x400 │ │ ├── filters%3Afill%28auto%29 │ │ │ └── find_trim.png │ │ └── filters%3Afill%28auto%2Cbottom-right%29 │ │ │ └── find_trim.png │ ├── 500x500 │ │ ├── filters%3Afill%28white%29%3Awatermark%28gopher.png%2C0.1%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-0.1%29 │ │ │ └── gopher.png │ │ ├── filters%3Afill%28white%29%3Awatermark%28gopher.png%2C10p%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-10p%29 │ │ │ └── gopher.png │ │ └── filters%3Afill%28white%29%3Awatermark%28gopher.png%2Cleft%2Ctop%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Cright%2Ccenter%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2C-20%2C-10%29 │ │ │ └── gopher.png │ ├── 50x0 │ │ └── filters%3Afill%28white%29%3Aformat%28jpg%29 │ │ │ └── Canon_40D.jpg │ ├── 67x67 │ │ ├── dancing-banana.gif │ │ ├── demo1.jpg │ │ ├── demo3.webp │ │ ├── filters%3Astrip_metadata%28%29 │ │ │ ├── dancing-banana.gif │ │ │ ├── demo1.jpg │ │ │ ├── demo3.webp │ │ │ └── gopher.tiff │ │ ├── gopher-front.png │ │ └── gopher.tiff │ ├── filters%3Alabel%28imagor%2C-1%2C0%2C50%29 │ │ └── 2bands.png │ └── stretch │ │ └── 100x100 │ │ └── 10x10 │ │ └── filters%3Afill%28transparent%29 │ │ └── gopher.png ├── gopher-front.png ├── max-filter-ops │ └── fit-in │ │ └── 200x150 │ │ ├── filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29 │ │ └── dancing-banana.gif │ │ └── filters%3Afill%28yellow%29 │ │ └── dancing-banana.gif ├── max-frames-limited │ ├── 200x100 │ │ └── top │ │ │ └── dancing-banana.gif │ ├── 30x20%3A100x150 │ │ └── dancing-banana.gif │ ├── dancing-banana.gif │ ├── filters%3Afill%28white%29%3Aformat%28jpeg%29 │ │ └── dancing-banana.gif │ ├── filters%3Amax_frames%286%29 │ │ └── dancing-banana.gif │ ├── fit-in │ │ └── 200x150 │ │ │ └── filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29 │ │ │ └── dancing-banana.gif │ ├── gopher-front.png │ └── trim │ │ └── dancing-banana.gif ├── max-frames │ ├── 200x100 │ │ └── top │ │ │ └── dancing-banana.gif │ ├── 30x20%3A100x150 │ │ └── dancing-banana.gif │ ├── dancing-banana.gif │ ├── filters%3Afill%28white%29%3Aformat%28jpeg%29 │ │ └── dancing-banana.gif │ ├── fit-in │ │ └── 200x150 │ │ │ └── filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29 │ │ │ └── dancing-banana.gif │ ├── gopher-front.png │ └── trim │ │ └── dancing-banana.gif ├── memory │ ├── 30x0 │ │ └── filters%3Aformat%28png%29 │ │ │ └── memory-test.png │ └── filters%3Aformat%28png%29 │ │ └── memory-test.png ├── meta │ ├── Canon_40D.jpg.json │ ├── filters%3Astrip_exif%28%29 │ │ └── Canon_40D.jpg.json │ ├── fit-in │ │ └── 100x100 │ │ │ ├── dancing-banana.gif.json │ │ │ ├── demo1.jpg.json │ │ │ └── filters%3Aformat%28jpg%29 │ │ │ └── dancing-banana.gif.json │ ├── gopher-front.heif.json │ ├── gopher.jp2.json │ ├── gopher.tiff.json │ ├── sample.pdf.json │ └── test.svg.json ├── no-animation │ ├── dancing-banana.gif │ └── gopher-front.png ├── stretch │ ├── 100x100 │ │ ├── 10x5 │ │ │ └── filters%3Afill%28white%29 │ │ │ │ └── gopher.png │ │ └── filters%3Amodulate%28-10%2C30%2C20%29 │ │ │ └── gopher.png │ └── 100x200 │ │ └── dancing-banana.gif ├── test.svg ├── trim%3A50 │ └── 500x500 │ │ └── filters%3Astretch%28%29 │ │ └── find_trim.png ├── trim%3Abottom-right │ ├── 500x500 │ │ └── filters%3Astrip_exif%28%29%3Aupscale%28%29%3Ano_upscale%28%29 │ │ │ └── find_trim.png │ └── 50x50%3A0x0 │ │ └── find_trim.png └── trim │ ├── filters%3Awatermark%28%29%3Ablur%282%29%3Asharpen%282%29%3Abrightness%28%29%3Acontrast%28%29%3Ahue%28%29%3Asaturation%28%29%3Argb%28%29%3Amodulate%28%29 │ └── dancing-banana.gif │ ├── find_trim_alpha.png │ └── fit-in │ └── 1000x1000 │ └── filters%3Aupscale%28%29%3Astrip_icc%28%29 │ └── find_trim.png ├── golden_arm64 ├── 100x100 │ ├── 10x5 │ │ └── top │ │ │ └── filters%3Afill%28yellow%29 │ │ │ └── dancing-banana.gif │ └── dancing-banana.gif ├── 100x200 │ ├── left │ │ ├── bottom │ │ │ └── dancing-banana.gif │ │ └── dancing-banana.gif │ └── right │ │ ├── dancing-banana.gif │ │ └── top │ │ └── dancing-banana.gif ├── 100x30 │ └── filters%3Afocal%280.89x0.72%29 │ │ └── dancing-banana.gif ├── 200x0 │ └── 20x20%3A100x20 │ │ └── filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-10%2C-10%2C0%2C50%2C50%29%3Awatermark%28dancing-banana.gif%2C-30%2C10%2C0%2C50%2C50%29 │ │ └── nyan-cat.gif ├── 200x100 │ ├── bottom │ │ └── dancing-banana.gif │ ├── left │ │ └── bottom │ │ │ └── dancing-banana.gif │ ├── right │ │ └── top │ │ │ └── dancing-banana.gif │ └── top │ │ └── dancing-banana.gif ├── 200x200 │ └── filters%3Aformat%28png%29%3Apalette%28%29%3Abitdepth%284%29%3Acompression%288%29 │ │ └── gopher.png ├── filters%3Aformat%28webp%29%3Aquality%2870%29 │ └── gopher-front.png ├── filters%3Awatermark%282bands.png%2Crepeat%2Cbottom%2C40%2C25%2C50%29 │ └── demo1.jpg ├── filters%3Awatermark%28demo1.jpg%2Crepeat%2Crepeat%2C40%2C25%2C50%29 │ └── demo1.jpg ├── fit-in │ ├── 100x100 │ │ └── demo3.webp │ ├── 100x150 │ │ └── filters%3Arotate%2890%29%3Afill%28yellow%29 │ │ │ └── dancing-banana.gif │ ├── 150x200 │ │ └── 10x00%3A10x50 │ │ │ ├── filters%3Afill%28cyan%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cwhite%2C0%2Cmonospace%29 │ │ │ └── dancing-banana.gif │ │ │ └── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cblack%29 │ │ │ └── dancing-banana.gif │ ├── 200x150 │ │ ├── filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29 │ │ │ └── dancing-banana.gif │ │ ├── filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29 │ │ │ └── dancing-banana.gif │ │ ├── filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C30%2C-10%2C0%2C40%2C40%29%3Awatermark%28dancing-banana.gif%2C0%2C10%2C0%2C40%2C40%29 │ │ │ └── nyan-cat.gif │ │ ├── filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2C-20%2C-10%2C0%2C30%2C30%29 │ │ │ └── dancing-banana.gif │ │ └── filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2Crepeat%2Cbottom%2C0%2C30%2C30%29 │ │ │ └── dancing-banana.gif │ ├── 300x200 │ │ └── 10x10 │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-0.15%2C0.1%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15%2C-10%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15p%2C10p%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C15%2C10%2C30%2Cblue%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2Cbottom%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ ├── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cleft%2Ctop%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ │ │ └── filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cright%2Ccenter%2C30%2Cred%2C30%29 │ │ │ └── gopher-front.png │ ├── 500x500 │ │ ├── filters%3Afill%28white%29%3Awatermark%28gopher.png%2C0.1%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-0.1%29 │ │ │ └── gopher.png │ │ └── filters%3Afill%28white%29%3Awatermark%28gopher.png%2C10p%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-10p%29 │ │ │ └── gopher.png │ └── 67x67 │ │ ├── dancing-banana.gif │ │ ├── demo3.webp │ │ ├── filters%3Astrip_metadata%28%29 │ │ ├── dancing-banana.gif │ │ └── demo3.webp │ │ └── gopher.tiff ├── max-frames-limited │ ├── 200x100 │ │ └── top │ │ │ └── dancing-banana.gif │ └── fit-in │ │ └── 200x150 │ │ └── filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29 │ │ └── dancing-banana.gif ├── max-frames │ ├── 200x100 │ │ └── top │ │ │ └── dancing-banana.gif │ └── fit-in │ │ └── 200x150 │ │ └── filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29 │ │ └── dancing-banana.gif └── stretch │ └── 100x200 │ └── dancing-banana.gif ├── gopher-exif-orientation-cw90.png ├── gopher-front.avif ├── gopher-front.heif ├── gopher-front.png ├── gopher.jp2 ├── gopher.png ├── gopher.tiff ├── lena_gray.bmp ├── nyan-cat.gif ├── sample.pdf └── test.svg /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*~ 2 | .git 3 | .env 4 | .idea 5 | bin 6 | tmp/ 7 | .DS_Store 8 | .vscode 9 | bin/imagor 10 | profile.cov 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: cshum -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: codeql 2 | 3 | on: 4 | push: 5 | branches: [ "master", "develop" ] 6 | pull_request: 7 | branches: [ "master", "develop" ] 8 | schedule: 9 | - cron: '0 16 * * 1' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v3 19 | 20 | # Initializes the CodeQL tools for scanning. 21 | - name: Initialize CodeQL 22 | uses: github/codeql-action/init@v2 23 | with: 24 | languages: go 25 | 26 | - name: Autobuild 27 | uses: github/codeql-action/autobuild@v2 28 | 29 | - name: Perform CodeQL Analysis 30 | uses: github/codeql-action/analyze@v2 31 | -------------------------------------------------------------------------------- /.github/workflows/docker-imagor-magick.yml: -------------------------------------------------------------------------------- 1 | name: docker-imagor-magick 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: [ 'v*.*.*' ] 8 | 9 | jobs: 10 | build: 11 | name: Docker imagor-magick 12 | runs-on: ubuntu-latest 13 | if: github.repository == 'cshum/imagor' 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v2 24 | with: 25 | platforms: arm64 26 | 27 | - name: Setup Docker buildx 28 | uses: docker/setup-buildx-action@v2 29 | 30 | - name: Login to GitHub Container Registry 31 | uses: docker/login-action@v2 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Extract Docker metadata 38 | id: meta 39 | uses: docker/metadata-action@v4 40 | with: 41 | images: ghcr.io/cshum/imagor-magick 42 | tags: | 43 | type=ref,event=branch 44 | type=semver,pattern={{version}} 45 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 46 | 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@v3 49 | with: 50 | context: . 51 | file: ./Dockerfile.imagor-magick 52 | platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} 53 | push: true 54 | tags: ${{ steps.meta.outputs.tags }} 55 | labels: ${{ steps.meta.outputs.labels }} 56 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: [ 'v*.*.*' ] 8 | 9 | jobs: 10 | build: 11 | name: Docker 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v2 23 | with: 24 | platforms: arm64 25 | 26 | - name: Setup Docker buildx 27 | uses: docker/setup-buildx-action@v2 28 | 29 | - name: Login to GitHub Container Registry 30 | uses: docker/login-action@v2 31 | with: 32 | registry: ghcr.io 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Login to Docker Hub 37 | if: github.repository == 'cshum/imagor' 38 | uses: docker/login-action@v2 39 | with: 40 | username: ${{ secrets.DOCKERHUB_USERNAME }} 41 | password: ${{ secrets.DOCKERHUB_TOKEN }} 42 | 43 | - name: Extract Docker metadata 44 | id: meta 45 | uses: docker/metadata-action@v4 46 | with: 47 | images: | 48 | name=ghcr.io/${{ github.repository }},enable=true 49 | name=shumc/imagor,enable=${{ github.repository == 'cshum/imagor' }} 50 | tags: | 51 | type=ref,event=branch 52 | type=semver,pattern={{version}} 53 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@v3 57 | with: 58 | context: . 59 | platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} 60 | push: true 61 | tags: ${{ steps.meta.outputs.tags }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | name: Test 12 | runs-on: ubuntu-22.04 13 | env: 14 | CGO_CFLAGS_ALLOW: -Xpreprocessor 15 | VIPS_VERSION: 8.16.1 16 | V: 6 17 | 18 | steps: 19 | - name: Set up Go 1.x 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: ^1.24 23 | 24 | - name: Check out code 25 | uses: actions/checkout@v2 26 | 27 | - name: Install linux dependencies 28 | run: | 29 | # Add backports for updated libheif 30 | echo "deb http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse" | sudo tee /etc/apt/sources.list.d/backports.list 31 | sudo apt-get update 32 | sudo apt-get install -y \ 33 | meson ninja-build \ 34 | libglib2.0-dev libexpat-dev librsvg2-dev libpng-dev \ 35 | libjpeg-turbo8-dev libimagequant-dev libfftw3-dev \ 36 | libpoppler-glib-dev libxml2-dev \ 37 | libopenslide-dev libcfitsio-dev liborc-0.4-dev libpango1.0-dev \ 38 | libtiff5-dev libgsf-1-dev giflib-tools libwebp-dev \ 39 | libopenjp2-7-dev libcgif-dev 40 | # Install newer libheif from backports 41 | sudo apt-get install -y -t jammy-backports libheif-dev || sudo apt-get install -y libheif-dev 42 | 43 | - name: Cache libvips 44 | uses: actions/cache@v3 45 | with: 46 | path: vips-${{ env.VIPS_VERSION }} 47 | key: ${{ runner.os }}-vips-${{ env.V }}-${{ env.VIPS_VERSION }} 48 | restore-keys: | 49 | ${{ runner.os }}-vips-${{ env.V }}- 50 | 51 | - name: Build libvips from source 52 | run: | 53 | if [ ! -d "vips-${{ env.VIPS_VERSION }}" ] 54 | then 55 | wget https://github.com/libvips/libvips/releases/download/v${{ env.VIPS_VERSION }}/vips-${{ env.VIPS_VERSION }}.tar.xz 56 | tar xf vips-${{ env.VIPS_VERSION }}.tar.xz 57 | fi 58 | cd vips-${{ env.VIPS_VERSION }} 59 | meson setup _build \ 60 | --buildtype=release \ 61 | --strip \ 62 | --prefix=/usr/local \ 63 | --libdir=lib \ 64 | -Dgtk_doc=false \ 65 | -Dmagick=disabled \ 66 | -Dintrospection=disabled 67 | ninja -C _build 68 | sudo ninja -C _build install 69 | sudo ldconfig 70 | 71 | - name: Cache dependencies 72 | uses: actions/cache@v3 73 | with: 74 | path: | 75 | ~/.cache/go-build 76 | ~/go/pkg/mod 77 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 78 | restore-keys: | 79 | ${{ runner.os }}-go- 80 | 81 | - name: Get dependencies 82 | run: make get 83 | 84 | - name: Test 85 | run: make test 86 | 87 | - name: Commit golden files 88 | if: github.event_name != 'pull_request' 89 | uses: stefanzweifel/git-auto-commit-action@v4 90 | with: 91 | commit_message: "test: update golden files" 92 | file_pattern: "testdata/golden" 93 | 94 | - name: Coveralls 95 | uses: shogo82148/actions-goveralls@v1 96 | with: 97 | path-to-profile: profile.cov 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | **/*.log 3 | **/*.sqlite 4 | .idea/ 5 | .DS_Store 6 | bin/ 7 | tmp/ 8 | .vscode/ 9 | .env 10 | bin/imagor 11 | profile.cov 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOLANG_VERSION=1.24.3 2 | FROM golang:${GOLANG_VERSION}-bookworm as builder 3 | 4 | ARG VIPS_VERSION=8.16.1 5 | ARG TARGETARCH 6 | 7 | ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig 8 | 9 | # Installs libvips + required libraries 10 | RUN DEBIAN_FRONTEND=noninteractive \ 11 | apt-get update && \ 12 | apt-get install --no-install-recommends -y \ 13 | ca-certificates \ 14 | automake build-essential curl \ 15 | meson ninja-build pkg-config \ 16 | gobject-introspection gtk-doc-tools libglib2.0-dev libjpeg62-turbo-dev libpng-dev \ 17 | libwebp-dev libtiff-dev libexif-dev libxml2-dev libpoppler-glib-dev \ 18 | swig libpango1.0-dev libmatio-dev libopenslide-dev libcfitsio-dev libopenjp2-7-dev liblcms2-dev \ 19 | libgsf-1-dev libfftw3-dev liborc-0.4-dev librsvg2-dev libimagequant-dev libaom-dev \ 20 | libheif-dev libspng-dev libcgif-dev && \ 21 | cd /tmp && \ 22 | curl -fsSLO https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz && \ 23 | tar xf vips-${VIPS_VERSION}.tar.xz && \ 24 | cd vips-${VIPS_VERSION} && \ 25 | meson setup _build \ 26 | --buildtype=release \ 27 | --strip \ 28 | --prefix=/usr/local \ 29 | --libdir=lib \ 30 | -Dgtk_doc=false \ 31 | -Dmagick=disabled \ 32 | -Dintrospection=disabled && \ 33 | ninja -C _build && \ 34 | ninja -C _build install && \ 35 | ldconfig && \ 36 | rm -rf /usr/local/lib/libvips-cpp.* && \ 37 | rm -rf /usr/local/lib/*.a && \ 38 | rm -rf /usr/local/lib/*.la 39 | 40 | WORKDIR ${GOPATH}/src/github.com/cshum/imagor 41 | 42 | COPY go.mod . 43 | COPY go.sum . 44 | 45 | RUN go mod download 46 | 47 | COPY . . 48 | 49 | RUN if [ "$TARGETARCH" = "amd64" ]; then go test ./...; fi 50 | RUN go build -o ${GOPATH}/bin/imagor ./cmd/imagor/main.go 51 | 52 | FROM debian:bookworm-slim 53 | LABEL maintainer="adrian@cshum.com" 54 | 55 | COPY --from=builder /usr/local/lib /usr/local/lib 56 | COPY --from=builder /etc/ssl/certs /etc/ssl/certs 57 | 58 | # Install runtime dependencies 59 | RUN echo "deb http://deb.debian.org/debian bookworm-backports main" > /etc/apt/sources.list.d/backports.list && \ 60 | DEBIAN_FRONTEND=noninteractive \ 61 | apt-get update && \ 62 | apt-get install --no-install-recommends -y \ 63 | procps libglib2.0-0 libjpeg62-turbo libpng16-16 libopenexr-3-1-30 \ 64 | libwebp7 libwebpmux3 libwebpdemux2 libtiff6 libexif12 libxml2 libpoppler-glib8 \ 65 | libpango1.0-0 libmatio11 libopenslide0 libopenjp2-7 libjemalloc2 \ 66 | libgsf-1-114 libfftw3-bin liborc-0.4-0 librsvg2-2 libcfitsio10 libimagequant0 libaom3 \ 67 | libspng0 libcgif0 && \ 68 | apt-get install --no-install-recommends -y -t bookworm-backports libheif1 && \ 69 | ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ 70 | apt-get autoremove -y && \ 71 | apt-get autoclean && \ 72 | apt-get clean && \ 73 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 74 | 75 | COPY --from=builder /go/bin/imagor /usr/local/bin/imagor 76 | 77 | ENV VIPS_WARNING=0 78 | ENV MALLOC_ARENA_MAX=2 79 | ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so 80 | 81 | ENV PORT 8000 82 | 83 | # use unprivileged user 84 | USER nobody 85 | 86 | ENTRYPOINT ["/usr/local/bin/imagor"] 87 | 88 | EXPOSE ${PORT} 89 | -------------------------------------------------------------------------------- /Dockerfile.imagor-magick: -------------------------------------------------------------------------------- 1 | ARG GOLANG_VERSION=1.24.3 2 | FROM golang:${GOLANG_VERSION}-bookworm as builder 3 | 4 | ARG VIPS_VERSION=8.16.1 5 | ARG TARGETARCH 6 | 7 | ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig 8 | 9 | # Installs libvips + required libraries + ImageMagick 10 | RUN DEBIAN_FRONTEND=noninteractive \ 11 | apt-get update && \ 12 | apt-get install --no-install-recommends -y \ 13 | ca-certificates \ 14 | automake build-essential curl \ 15 | meson ninja-build pkg-config \ 16 | gobject-introspection gtk-doc-tools libglib2.0-dev libjpeg62-turbo-dev libpng-dev \ 17 | libwebp-dev libtiff-dev libexif-dev libxml2-dev libpoppler-glib-dev \ 18 | swig libpango1.0-dev libmatio-dev libopenslide-dev libcfitsio-dev libopenjp2-7-dev liblcms2-dev \ 19 | libgsf-1-dev libfftw3-dev liborc-0.4-dev librsvg2-dev libimagequant-dev libaom-dev \ 20 | libheif-dev libspng-dev libcgif-dev \ 21 | libmagickwand-dev && \ 22 | cd /tmp && \ 23 | curl -fsSLO https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz && \ 24 | tar xf vips-${VIPS_VERSION}.tar.xz && \ 25 | cd vips-${VIPS_VERSION} && \ 26 | meson setup _build \ 27 | --buildtype=release \ 28 | --strip \ 29 | --prefix=/usr/local \ 30 | --libdir=lib \ 31 | -Dgtk_doc=false \ 32 | -Dmagick=enabled \ 33 | -Dintrospection=disabled && \ 34 | ninja -C _build && \ 35 | ninja -C _build install && \ 36 | ldconfig && \ 37 | rm -rf /usr/local/lib/libvips-cpp.* && \ 38 | rm -rf /usr/local/lib/*.a && \ 39 | rm -rf /usr/local/lib/*.la 40 | 41 | WORKDIR ${GOPATH}/src/github.com/cshum/imagor 42 | 43 | COPY go.mod . 44 | COPY go.sum . 45 | 46 | RUN go mod download 47 | 48 | COPY . . 49 | 50 | RUN if [ "$TARGETARCH" = "amd64" ]; then go test ./...; fi 51 | RUN go build -o ${GOPATH}/bin/imagor ./cmd/imagor/main.go 52 | 53 | FROM debian:bookworm-slim 54 | LABEL maintainer="adrian@cshum.com" 55 | 56 | COPY --from=builder /usr/local/lib /usr/local/lib 57 | COPY --from=builder /etc/ssl/certs /etc/ssl/certs 58 | 59 | # Install runtime dependencies including ImageMagick 60 | RUN echo "deb http://deb.debian.org/debian bookworm-backports main" > /etc/apt/sources.list.d/backports.list && \ 61 | DEBIAN_FRONTEND=noninteractive \ 62 | apt-get update && \ 63 | apt-get install --no-install-recommends -y \ 64 | procps libglib2.0-0 libjpeg62-turbo libpng16-16 libopenexr-3-1-30 \ 65 | libwebp7 libwebpmux3 libwebpdemux2 libtiff6 libexif12 libxml2 libpoppler-glib8 \ 66 | libpango1.0-0 libmatio11 libopenslide0 libopenjp2-7 libjemalloc2 \ 67 | libgsf-1-114 libfftw3-bin liborc-0.4-0 librsvg2-2 libcfitsio10 libimagequant0 libaom3 \ 68 | libspng0 libcgif0 \ 69 | libmagickwand-6.q16-6 && \ 70 | apt-get install --no-install-recommends -y -t bookworm-backports libheif1 && \ 71 | ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ 72 | apt-get autoremove -y && \ 73 | apt-get autoclean && \ 74 | apt-get clean && \ 75 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 76 | 77 | COPY --from=builder /go/bin/imagor /usr/local/bin/imagor 78 | 79 | ENV VIPS_WARNING=0 80 | ENV MALLOC_ARENA_MAX=2 81 | ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so 82 | 83 | ENV PORT 8000 84 | 85 | # use unprivileged user 86 | USER nobody 87 | 88 | ENTRYPOINT ["/usr/local/bin/imagor"] 89 | 90 | EXPOSE ${PORT} 91 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | CGO_CFLAGS_ALLOW=-Xpreprocessor go build -o bin/imagor ./cmd/imagor/main.go 3 | 4 | test: 5 | go clean -testcache && CGO_CFLAGS_ALLOW=-Xpreprocessor go test -coverprofile=profile.cov $(shell go list ./... | grep -v /examples/ | grep -v /cmd/) 6 | 7 | dev: build 8 | ./bin/imagor -debug -imagor-unsafe 9 | 10 | help: build 11 | ./bin/imagor -h 12 | 13 | get: 14 | go get -v -t -d ./... 15 | 16 | docker-dev-build: 17 | docker build -t imagor:dev . 18 | 19 | docker-dev-run: 20 | touch .env 21 | docker run --rm -p 8000:8000 --env-file .env imagor:dev -debug -imagor-unsafe 22 | 23 | docker-dev: docker-dev-build docker-dev-run 24 | 25 | %-tag: VERSION:=$(if $(VERSION),$(VERSION),$$(./bin/imagor -version)) 26 | 27 | git-tag: 28 | git tag "v$(VERSION)" 29 | git push origin "refs/tags/v$(VERSION)" 30 | 31 | reset-golden: 32 | git rm -rf testdata/golden 33 | git commit -m "test: reset golden" 34 | git push 35 | 36 | release: build git-tag 37 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imagor", 3 | "description": "Fast, Docker-ready image processing server in Go with libvips", 4 | "keywords": [ 5 | "image", 6 | "resize-images", 7 | "crop-image", 8 | "microservice", 9 | "docker", 10 | "jpeg", 11 | "png", 12 | "libvips" 13 | ], 14 | "repository": "https://github.com/cshum/imagor", 15 | "stack": "container", 16 | "env": { 17 | "IMAGOR_UNSAFE": { 18 | "description": "Use Unsafe mode, default 1 for testing. In production environment, it is highly recommended turning off `IMAGOR_UNSAFE` and setting up URL signature using `IMAGOR_SECRET`, to prevent DDoS attacks that abuse multiple image operations.", 19 | "required": true, 20 | "value": "1" 21 | }, 22 | "IMAGOR_SECRET": { 23 | "description": "Secret key for URL signature.", 24 | "required": false 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /cmd/imagor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cshum/imagor/config" 5 | "github.com/cshum/imagor/config/awsconfig" 6 | "github.com/cshum/imagor/config/gcloudconfig" 7 | "github.com/cshum/imagor/config/vipsconfig" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | var server = config.CreateServer( 13 | os.Args[1:], 14 | vipsconfig.WithVips, 15 | awsconfig.WithAWS, 16 | gcloudconfig.WithGCloud, 17 | ) 18 | if server != nil { 19 | server.Run() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config/fileconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/storage/filestorage" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // withFileSystem with File Loader, Storage, Result Storage based config option 11 | func withFileSystem(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 12 | var ( 13 | fileSafeChars = fs.String("file-safe-chars", "", 14 | "File safe characters to be excluded from image key escape. Set -- for no-op") 15 | fileLoaderBaseDir = fs.String("file-loader-base-dir", "", 16 | "Base directory for File Loader. Enable File Loader only if this value present") 17 | fileLoaderPathPrefix = fs.String("file-loader-path-prefix", "", 18 | "Base path prefix for File Loader") 19 | 20 | fileStorageBaseDir = fs.String("file-storage-base-dir", "", 21 | "Base directory for File Storage. Enable File Storage only if this value present") 22 | fileStoragePathPrefix = fs.String("file-storage-path-prefix", "", 23 | "Base path prefix for File Storage") 24 | fileStorageMkdirPermission = fs.String("file-storage-mkdir-permission", "0755", 25 | "File Storage mkdir permission") 26 | fileStorageWritePermission = fs.String("file-storage-write-permission", "0666", 27 | "File Storage write permission") 28 | fileStorageExpiration = fs.Duration("file-storage-expiration", 0, 29 | "File Storage expiration duration e.g. 24h. Default no expiration") 30 | 31 | fileResultStorageBaseDir = fs.String("file-result-storage-base-dir", "", 32 | "Base directory for File Result Storage. Enable File Result Storage only if this value present") 33 | fileResultStoragePathPrefix = fs.String("file-result-storage-path-prefix", "", 34 | "Base path prefix for File Result Storage") 35 | fileResultStorageMkdirPermission = fs.String("file-result-storage-mkdir-permission", "0755", 36 | "File Result Storage mkdir permission") 37 | fileResultStorageWritePermission = fs.String("file-result-storage-write-permission", "0666", 38 | "File Storage write permission") 39 | fileResultStorageExpiration = fs.Duration("file-result-storage-expiration", 0, 40 | "File Result Storage expiration duration e.g. 24h. Default no expiration") 41 | 42 | _, _ = cb() 43 | ) 44 | return func(o *imagor.Imagor) { 45 | if *fileStorageBaseDir != "" { 46 | // activate File Storage only if base dir config presents 47 | o.Storages = append(o.Storages, 48 | filestorage.New( 49 | *fileStorageBaseDir, 50 | filestorage.WithPathPrefix(*fileStoragePathPrefix), 51 | filestorage.WithMkdirPermission(*fileStorageMkdirPermission), 52 | filestorage.WithWritePermission(*fileStorageWritePermission), 53 | filestorage.WithSafeChars(*fileSafeChars), 54 | filestorage.WithExpiration(*fileStorageExpiration), 55 | ), 56 | ) 57 | } 58 | if *fileLoaderBaseDir != "" { 59 | // activate File Loader only if base dir config presents 60 | o.Loaders = append(o.Loaders, 61 | filestorage.New( 62 | *fileLoaderBaseDir, 63 | filestorage.WithPathPrefix(*fileLoaderPathPrefix), 64 | filestorage.WithSafeChars(*fileSafeChars), 65 | ), 66 | ) 67 | } 68 | if *fileResultStorageBaseDir != "" { 69 | // activate File Result Storage only if base dir config presents 70 | o.ResultStorages = append(o.ResultStorages, 71 | filestorage.New( 72 | *fileResultStorageBaseDir, 73 | filestorage.WithPathPrefix(*fileResultStoragePathPrefix), 74 | filestorage.WithMkdirPermission(*fileResultStorageMkdirPermission), 75 | filestorage.WithWritePermission(*fileResultStorageWritePermission), 76 | filestorage.WithSafeChars(*fileSafeChars), 77 | filestorage.WithExpiration(*fileResultStorageExpiration), 78 | ), 79 | ) 80 | } 81 | 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | ) 7 | 8 | // CIDRSliceFlag is a flag type which support comma separated CIDR expressions. 9 | type CIDRSliceFlag []*net.IPNet 10 | 11 | // String implements flag.Setter interface 12 | func (s *CIDRSliceFlag) String() string { 13 | var ss []string 14 | for _, v := range *s { 15 | ss = append(ss, v.String()) 16 | } 17 | return strings.Join(ss, ",") 18 | } 19 | 20 | // Set implements flag.Setter interface 21 | func (s *CIDRSliceFlag) Set(value string) error { 22 | var res []*net.IPNet 23 | for _, v := range strings.Split(value, ",") { 24 | _, network, err := net.ParseCIDR(v) 25 | if err != nil { 26 | return err 27 | } 28 | res = append(res, network) 29 | } 30 | *s = res 31 | return nil 32 | } 33 | 34 | // Get implements flag.Getter interface 35 | func (s *CIDRSliceFlag) Get() any { 36 | return s 37 | } 38 | -------------------------------------------------------------------------------- /config/flags_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCIDRSliceFlag(t *testing.T) { 10 | t.Run("set and get", func(t *testing.T) { 11 | var f CIDRSliceFlag 12 | input := "127.0.0.0/12,200.100.0.0/28" 13 | assert.NoError(t, f.Set(input)) 14 | assert.Equal(t, input, f.String()) 15 | assert.Equal(t, &f, f.Get()) 16 | 17 | }) 18 | t.Run("parse error", func(t *testing.T) { 19 | var f CIDRSliceFlag 20 | input := "127.0.0.0/12,200.100.0.0/28." 21 | assert.Error(t, f.Set(input)) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /config/gcloudconfig/gcloudconfig.go: -------------------------------------------------------------------------------- 1 | package gcloudconfig 2 | 3 | import ( 4 | "cloud.google.com/go/storage" 5 | "context" 6 | "flag" 7 | "github.com/cshum/imagor" 8 | "github.com/cshum/imagor/storage/gcloudstorage" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | // WithGCloud with Google Cloud Loader, Storage, Result Storage config option 13 | func WithGCloud(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 14 | var ( 15 | gcloudSafeChars = fs.String("gcloud-safe-chars", "", 16 | "Google Cloud safe characters to be excluded from image key escape. Set -- for no-op") 17 | 18 | gcloudLoaderBucket = fs.String("gcloud-loader-bucket", "", 19 | "Bucket name for Google Cloud Storage Loader. Enable Google Cloud Loader only if this value present") 20 | gcloudLoaderBaseDir = fs.String("gcloud-loader-base-dir", "", 21 | "Base directory for Google Cloud Loader") 22 | gcloudLoaderPathPrefix = fs.String("gcloud-loader-path-prefix", "", 23 | "Base path prefix for Google Cloud Loader") 24 | 25 | gcloudStorageBucket = fs.String("gcloud-storage-bucket", "", 26 | "Bucket name for Google Cloud Storage. Enable Google Cloud Storage only if this value present") 27 | gcloudStorageBaseDir = fs.String("gcloud-storage-base-dir", "", 28 | "Base directory for Google Cloud") 29 | gcloudStoragePathPrefix = fs.String("gcloud-storage-path-prefix", "", 30 | "Base path prefix for Google Cloud Storage") 31 | gcloudStorageACL = fs.String("gcloud-storage-acl", "", 32 | "Upload ACL for Google Cloud Storage") 33 | gcloudStorageExpiration = fs.Duration("gcloud-storage-expiration", 0, 34 | "Google Cloud Storage expiration duration e.g. 24h. Default no expiration") 35 | 36 | gcloudResultStorageBucket = fs.String("gcloud-result-storage-bucket", "", 37 | "Bucket name for Google Cloud Result Storage. Enable Google Cloud Result Storage only if this value present") 38 | gcloudResultStorageBaseDir = fs.String("gcloud-result-storage-base-dir", "", 39 | "Base directory for Google Cloud Result Storage") 40 | gcloudResultStoragePathPrefix = fs.String("gcloud-result-storage-path-prefix", "", 41 | "Base path prefix for Google Cloud Result Storage") 42 | gcloudResultStorageACL = fs.String("gcloud-result-storage-acl", "", 43 | "Upload ACL for Google Cloud Result Storage") 44 | gcloudResultStorageExpiration = fs.Duration("gcloud-result-storage-expiration", 0, 45 | "Google Cloud Result Storage expiration duration e.g. 24h. Default no expiration") 46 | 47 | _, _ = cb() 48 | ) 49 | return func(app *imagor.Imagor) { 50 | if *gcloudStorageBucket != "" || *gcloudLoaderBucket != "" || *gcloudResultStorageBucket != "" { 51 | // Activate the session, will panic if credentials are missing 52 | // Google cloud uses credentials from GOOGLE_APPLICATION_CREDENTIALS env file 53 | gcloudClient, err := storage.NewClient(context.Background()) 54 | if err != nil { 55 | panic(err) 56 | } 57 | if *gcloudStorageBucket != "" { 58 | // activate Google Cloud Storage only if bucket config presents 59 | app.Storages = append(app.Storages, 60 | gcloudstorage.New(gcloudClient, *gcloudStorageBucket, 61 | gcloudstorage.WithPathPrefix(*gcloudStoragePathPrefix), 62 | gcloudstorage.WithBaseDir(*gcloudStorageBaseDir), 63 | gcloudstorage.WithACL(*gcloudStorageACL), 64 | gcloudstorage.WithSafeChars(*gcloudSafeChars), 65 | gcloudstorage.WithExpiration(*gcloudStorageExpiration), 66 | ), 67 | ) 68 | } 69 | 70 | if *gcloudLoaderBucket != "" { 71 | // activate Google Cloud Loader only if bucket config presents 72 | app.Loaders = append(app.Loaders, 73 | gcloudstorage.New(gcloudClient, *gcloudLoaderBucket, 74 | gcloudstorage.WithPathPrefix(*gcloudLoaderPathPrefix), 75 | gcloudstorage.WithBaseDir(*gcloudLoaderBaseDir), 76 | gcloudstorage.WithSafeChars(*gcloudSafeChars), 77 | ), 78 | ) 79 | } 80 | if *gcloudResultStorageBucket != "" { 81 | // activate Google Cloud ResultStorage only if bucket config presents 82 | app.ResultStorages = append(app.ResultStorages, 83 | gcloudstorage.New(gcloudClient, *gcloudResultStorageBucket, 84 | gcloudstorage.WithPathPrefix(*gcloudResultStoragePathPrefix), 85 | gcloudstorage.WithBaseDir(*gcloudResultStorageBaseDir), 86 | gcloudstorage.WithACL(*gcloudResultStorageACL), 87 | gcloudstorage.WithSafeChars(*gcloudSafeChars), 88 | gcloudstorage.WithExpiration(*gcloudResultStorageExpiration), 89 | ), 90 | ) 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /config/gcloudconfig/gcloudconfig_test.go: -------------------------------------------------------------------------------- 1 | package gcloudconfig 2 | 3 | import ( 4 | "github.com/cshum/imagor" 5 | "github.com/cshum/imagor/config" 6 | "github.com/cshum/imagor/storage/gcloudstorage" 7 | "github.com/fsouza/fake-gcs-server/fakestorage" 8 | "github.com/stretchr/testify/assert" 9 | "os" 10 | "testing" 11 | ) 12 | 13 | func fakeGCSServer() *fakestorage.Server { 14 | if err := os.Setenv("STORAGE_EMULATOR_HOST", "localhost:12345"); err != nil { 15 | panic(err) 16 | } 17 | svr, err := fakestorage.NewServerWithOptions(fakestorage.Options{ 18 | Host: "localhost", Port: 12345, 19 | }) 20 | if err != nil { 21 | panic(err) 22 | } 23 | return svr 24 | } 25 | 26 | func TestGCSLoader(t *testing.T) { 27 | svr := fakeGCSServer() 28 | defer svr.Stop() 29 | 30 | srv := config.CreateServer([]string{ 31 | "-gcloud-safe-chars", "!", 32 | 33 | "-gcloud-loader-bucket", "a", 34 | "-gcloud-loader-base-dir", "foo", 35 | "-gcloud-loader-path-prefix", "abcd", 36 | }, WithGCloud) 37 | app := srv.App.(*imagor.Imagor) 38 | loader := app.Loaders[0].(*gcloudstorage.GCloudStorage) 39 | assert.Equal(t, "a", loader.Bucket) 40 | assert.Equal(t, "foo", loader.BaseDir) 41 | assert.Equal(t, "/abcd/", loader.PathPrefix) 42 | assert.Equal(t, "!", loader.SafeChars) 43 | } 44 | 45 | func TestGCSStorage(t *testing.T) { 46 | svr := fakeGCSServer() 47 | defer svr.Stop() 48 | 49 | srv := config.CreateServer([]string{ 50 | "-gcloud-safe-chars", "!", 51 | 52 | "-gcloud-storage-bucket", "a", 53 | "-gcloud-storage-base-dir", "foo", 54 | "-gcloud-storage-path-prefix", "abcd", 55 | 56 | "-gcloud-result-storage-bucket", "b", 57 | "-gcloud-result-storage-base-dir", "bar", 58 | "-gcloud-result-storage-path-prefix", "bcda", 59 | }, WithGCloud) 60 | app := srv.App.(*imagor.Imagor) 61 | assert.Equal(t, 1, len(app.Loaders)) 62 | storage := app.Storages[0].(*gcloudstorage.GCloudStorage) 63 | assert.Equal(t, "a", storage.Bucket) 64 | assert.Equal(t, "foo", storage.BaseDir) 65 | assert.Equal(t, "/abcd/", storage.PathPrefix) 66 | assert.Equal(t, "!", storage.SafeChars) 67 | 68 | resultStorage := app.ResultStorages[0].(*gcloudstorage.GCloudStorage) 69 | assert.Equal(t, "b", resultStorage.Bucket) 70 | assert.Equal(t, "bar", resultStorage.BaseDir) 71 | assert.Equal(t, "/bcda/", resultStorage.PathPrefix) 72 | assert.Equal(t, "!", resultStorage.SafeChars) 73 | } 74 | -------------------------------------------------------------------------------- /config/option.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "github.com/cshum/imagor" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // Option flag based config option 10 | type Option func(fs *flag.FlagSet, cb func() (logger *zap.Logger, isDebug bool)) imagor.Option 11 | 12 | // applyOptions transform from config.Option to imagor.Option 13 | func applyOptions( 14 | fs *flag.FlagSet, cb func() (*zap.Logger, bool), options ...Option, 15 | ) (imagorOptions []imagor.Option, logger *zap.Logger, isDebug bool) { 16 | if len(options) == 0 { 17 | logger, isDebug = cb() 18 | return 19 | } 20 | var last = len(options) - 1 21 | var called bool 22 | if options[last] == nil { 23 | return applyOptions(fs, cb, options[:last]...) 24 | } 25 | imagorOptions = append(imagorOptions, options[last](fs, func() (*zap.Logger, bool) { 26 | imagorOptions, logger, isDebug = applyOptions(fs, cb, options[:last]...) 27 | called = true 28 | return logger, isDebug 29 | })) 30 | if !called { 31 | var opts []imagor.Option 32 | opts, logger, isDebug = applyOptions(fs, cb, options[:last]...) 33 | imagorOptions = append(opts, imagorOptions...) 34 | return imagorOptions, logger, isDebug 35 | } 36 | return 37 | } 38 | -------------------------------------------------------------------------------- /config/option_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "github.com/cshum/imagor" 6 | "github.com/stretchr/testify/assert" 7 | "go.uber.org/zap" 8 | "testing" 9 | ) 10 | 11 | func TestApplyOptions(t *testing.T) { 12 | fs := flag.NewFlagSet("imagor", flag.ExitOnError) 13 | nopLogger := zap.NewNop() 14 | var seq []int 15 | options, logger, isDebug := applyOptions(fs, func() (logger *zap.Logger, isDebug bool) { 16 | seq = append(seq, 4) 17 | return nopLogger, true 18 | }, func(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 19 | seq = append(seq, 3) 20 | logger, isDebug := cb() 21 | assert.Equal(t, nopLogger, logger) 22 | assert.True(t, isDebug) 23 | seq = append(seq, 5) 24 | return func(app *imagor.Imagor) { 25 | seq = append(seq, 8) 26 | } 27 | }, func(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 28 | seq = append(seq, 2) 29 | logger, isDebug := cb() 30 | assert.Equal(t, nopLogger, logger) 31 | assert.True(t, isDebug) 32 | seq = append(seq, 6) 33 | return func(app *imagor.Imagor) { 34 | seq = append(seq, 9) 35 | } 36 | }, func(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 37 | seq = append(seq, 1) 38 | logger, isDebug := cb() 39 | assert.Equal(t, nopLogger, logger) 40 | assert.True(t, isDebug) 41 | seq = append(seq, 7) 42 | return func(app *imagor.Imagor) { 43 | seq = append(seq, 10) 44 | } 45 | }) 46 | imagor.New(options...) 47 | assert.Equal(t, nopLogger, logger) 48 | assert.True(t, isDebug) 49 | assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, seq) 50 | } 51 | 52 | func TestApplyFuncsNil(t *testing.T) { 53 | fs := flag.NewFlagSet("imagor", flag.ExitOnError) 54 | nopLogger := zap.NewNop() 55 | var seq []int 56 | options, logger, isDebug := applyOptions(fs, func() (logger *zap.Logger, isDebug bool) { 57 | seq = append(seq, 4) 58 | return nopLogger, true 59 | }, func(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 60 | seq = append(seq, 3) 61 | logger, isDebug := cb() 62 | assert.Equal(t, nopLogger, logger) 63 | assert.True(t, isDebug) 64 | seq = append(seq, 5) 65 | return func(app *imagor.Imagor) { 66 | seq = append(seq, 7) 67 | } 68 | }, nil, func(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 69 | seq = append(seq, 2) 70 | return func(app *imagor.Imagor) { 71 | seq = append(seq, 8) 72 | } 73 | }, nil, func(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 74 | seq = append(seq, 1) 75 | logger, isDebug := cb() 76 | assert.Equal(t, nopLogger, logger) 77 | assert.True(t, isDebug) 78 | seq = append(seq, 6) 79 | return func(app *imagor.Imagor) { 80 | seq = append(seq, 9) 81 | } 82 | }) 83 | imagor.New(options...) 84 | assert.Equal(t, nopLogger, logger) 85 | assert.True(t, isDebug) 86 | assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, seq) 87 | } 88 | -------------------------------------------------------------------------------- /config/vipsconfig/vipsconfig.go: -------------------------------------------------------------------------------- 1 | package vipsconfig 2 | 3 | import ( 4 | "flag" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/processor/vipsprocessor" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // WithVips with libvips processor config option 11 | func WithVips(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Option { 12 | var ( 13 | vipsDisableBlur = fs.Bool("vips-disable-blur", false, 14 | "VIPS disable blur operations for vips processor") 15 | vipsMaxAnimationFrames = fs.Int("vips-max-animation-frames", -1, 16 | "VIPS maximum number of animation frames to be loaded. Set 1 to disable animation, -1 for unlimited") 17 | vipsDisableFilters = fs.String("vips-disable-filters", "", 18 | "VIPS disable filters by csv e.g. blur,watermark,rgb") 19 | vipsMaxFilterOps = fs.Int("vips-max-filter-ops", -1, 20 | "VIPS maximum number of filter operations allowed. Set -1 for unlimited") 21 | vipsConcurrency = fs.Int("vips-concurrency", 1, 22 | "VIPS concurrency. Set -1 to be the number of CPU cores") 23 | vipsMaxCacheFiles = fs.Int("vips-max-cache-files", 0, 24 | "VIPS max cache files") 25 | vipsMaxCacheSize = fs.Int("vips-max-cache-size", 0, 26 | "VIPS max cache size") 27 | vipsMaxCacheMem = fs.Int("vips-max-cache-mem", 0, 28 | "VIPS max cache mem") 29 | vipsMaxWidth = fs.Int("vips-max-width", 0, 30 | "VIPS max image width") 31 | vipsMaxHeight = fs.Int("vips-max-height", 0, 32 | "VIPS max image height") 33 | vipsMaxResolution = fs.Int("vips-max-resolution", 0, 34 | "VIPS max image resolution") 35 | vipsMozJPEG = fs.Bool("vips-mozjpeg", false, 36 | "VIPS enable maximum compression with MozJPEG. Requires mozjpeg to be installed") 37 | vipsAvifSpeed = fs.Int("vips-avif-speed", 5, 38 | "VIPS avif speed, the lowest is at 0 and the fastest is at 9 (Default 5).") 39 | vipsStripMetadata = fs.Bool("vips-strip-metadata", false, 40 | "VIPS strips all metadata from the resulting image") 41 | vipsUnlimited = fs.Bool("vips-unlimited", false, 42 | "VIPS bypass image max resolution check and remove all denial of service limits") 43 | 44 | logger, isDebug = cb() 45 | ) 46 | return imagor.WithProcessors( 47 | vipsprocessor.NewProcessor( 48 | vipsprocessor.WithMaxAnimationFrames(*vipsMaxAnimationFrames), 49 | vipsprocessor.WithDisableBlur(*vipsDisableBlur), 50 | vipsprocessor.WithDisableFilters(*vipsDisableFilters), 51 | vipsprocessor.WithConcurrency(*vipsConcurrency), 52 | vipsprocessor.WithMaxCacheFiles(*vipsMaxCacheFiles), 53 | vipsprocessor.WithMaxCacheMem(*vipsMaxCacheMem), 54 | vipsprocessor.WithMaxCacheSize(*vipsMaxCacheSize), 55 | vipsprocessor.WithMaxFilterOps(*vipsMaxFilterOps), 56 | vipsprocessor.WithMaxWidth(*vipsMaxWidth), 57 | vipsprocessor.WithMaxHeight(*vipsMaxHeight), 58 | vipsprocessor.WithMaxResolution(*vipsMaxResolution), 59 | vipsprocessor.WithMozJPEG(*vipsMozJPEG), 60 | vipsprocessor.WithAvifSpeed(*vipsAvifSpeed), 61 | vipsprocessor.WithStripMetadata(*vipsStripMetadata), 62 | vipsprocessor.WithUnlimited(*vipsUnlimited), 63 | vipsprocessor.WithLogger(logger), 64 | vipsprocessor.WithDebug(isDebug), 65 | ), 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /config/vipsconfig/vipsconfig_test.go: -------------------------------------------------------------------------------- 1 | package vipsconfig 2 | 3 | import ( 4 | "github.com/cshum/imagor" 5 | "github.com/cshum/imagor/config" 6 | "github.com/cshum/imagor/processor/vipsprocessor" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | ) 10 | 11 | func TestWithVips(t *testing.T) { 12 | srv := config.CreateServer([]string{ 13 | "-vips-max-animation-frames", "167", 14 | "-vips-disable-filters", "blur,watermark,rgb", 15 | }, WithVips) 16 | app := srv.App.(*imagor.Imagor) 17 | processor := app.Processors[0].(*vipsprocessor.Processor) 18 | assert.Equal(t, 167, processor.MaxAnimationFrames) 19 | assert.Equal(t, []string{"blur", "watermark", "rgb"}, processor.DisableFilters) 20 | } 21 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package imagor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | type contextKey struct { 11 | Type int8 12 | } 13 | 14 | var imagorContextKey = contextKey{1} 15 | var detachContextKey = contextKey{2} 16 | 17 | type imagorContextRef struct { 18 | funcs []func() 19 | l sync.Mutex 20 | 21 | Blob *Blob 22 | } 23 | 24 | func (r *imagorContextRef) Defer(fn func()) { 25 | r.l.Lock() 26 | r.funcs = append(r.funcs, fn) 27 | r.l.Unlock() 28 | } 29 | 30 | func (r *imagorContextRef) Done() { 31 | r.l.Lock() 32 | for _, fn := range r.funcs { 33 | fn() 34 | } 35 | r.funcs = nil 36 | r.l.Unlock() 37 | } 38 | 39 | // withContext context with imagor defer handling and cache 40 | func withContext(ctx context.Context) context.Context { 41 | if r, ok := ctx.Value(imagorContextKey).(*imagorContextRef); ok && r != nil { 42 | return ctx 43 | } 44 | r := &imagorContextRef{} 45 | ctx = context.WithValue(ctx, imagorContextKey, r) 46 | go func() { 47 | <-ctx.Done() 48 | r.Done() 49 | }() 50 | return ctx 51 | } 52 | 53 | func mustContextRef(ctx context.Context) *imagorContextRef { 54 | if r, ok := ctx.Value(imagorContextKey).(*imagorContextRef); ok && r != nil { 55 | return r 56 | } 57 | panic(errors.New("not imagor context")) 58 | } 59 | 60 | // contextDefer add func to context, defer called at the end of request 61 | func contextDefer(ctx context.Context, fn func()) { 62 | mustContextRef(ctx).Defer(fn) 63 | } 64 | 65 | type detachedContext struct { 66 | ctx context.Context 67 | } 68 | 69 | func (detachedContext) Deadline() (time.Time, bool) { 70 | return time.Time{}, false 71 | } 72 | 73 | func (detachedContext) Done() <-chan struct{} { 74 | return nil 75 | } 76 | 77 | func (detachedContext) Err() error { 78 | return nil 79 | } 80 | 81 | func (d detachedContext) Value(key any) any { 82 | if key == detachContextKey { 83 | return true 84 | } 85 | return d.ctx.Value(key) 86 | } 87 | 88 | // detachContext returns a context that keeps all the values of its parent context 89 | // but detaches from cancellation and timeout 90 | func detachContext(ctx context.Context) context.Context { 91 | return detachedContext{ctx: ctx} 92 | } 93 | 94 | // isDetached returns if context is detached 95 | func isDetached(ctx context.Context) bool { 96 | _, ok := ctx.Value(detachContextKey).(bool) 97 | return ok 98 | } 99 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package imagor 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestDefer(t *testing.T) { 11 | var called int 12 | ctx, cancel := context.WithCancel(context.Background()) 13 | assert.Panics(t, func() { 14 | contextDefer(ctx, func() { 15 | t.Fatal("should not call") 16 | }) 17 | }) 18 | ctx = withContext(ctx) 19 | contextDefer(ctx, func() { 20 | called++ 21 | }) 22 | contextDefer(ctx, func() { 23 | called++ 24 | }) 25 | cancel() 26 | assert.Equal(t, 0, called, "should call after signal") 27 | time.Sleep(time.Millisecond * 10) 28 | contextDefer(ctx, func() { 29 | called++ 30 | }) 31 | assert.Equal(t, 2, called, "should count all defers before cancel") 32 | } 33 | 34 | func TestDetachContext(t *testing.T) { 35 | ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) 36 | defer cancel() 37 | ctx = context.WithValue(ctx, "foo", "bar") 38 | assert.False(t, isDetached(ctx)) 39 | time.Sleep(time.Millisecond) 40 | assert.Equal(t, ctx.Err(), context.DeadlineExceeded) 41 | ctx = detachContext(ctx) 42 | assert.True(t, isDetached(ctx)) 43 | assert.Equal(t, "bar", ctx.Value("foo")) 44 | assert.NoError(t, ctx.Err()) 45 | ctx, cancel2 := context.WithTimeout(ctx, time.Millisecond*5) 46 | defer cancel2() 47 | assert.NoError(t, ctx.Err()) 48 | assert.True(t, isDetached(ctx)) 49 | time.Sleep(time.Millisecond * 10) 50 | assert.Equal(t, ctx.Err(), context.DeadlineExceeded) 51 | } 52 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package imagor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/cshum/imagor/imagorpath" 13 | ) 14 | 15 | var ( 16 | // ErrNotFound not found error 17 | ErrNotFound = NewError("not found", http.StatusNotFound) 18 | // ErrInvalid syntactic invalid path error 19 | ErrInvalid = NewError("invalid", http.StatusBadRequest) 20 | // ErrMethodNotAllowed method not allowed error 21 | ErrMethodNotAllowed = NewError("method not allowed", http.StatusMethodNotAllowed) 22 | // ErrSourceNotAllowed http source not allowed error 23 | ErrSourceNotAllowed = NewError("http source not allowed", http.StatusForbidden) 24 | // ErrSignatureMismatch URL signature mismatch error 25 | ErrSignatureMismatch = NewError("url signature mismatch", http.StatusForbidden) 26 | // ErrTimeout timeout error 27 | ErrTimeout = NewError("timeout", http.StatusRequestTimeout) 28 | // ErrExpired expire error 29 | ErrExpired = NewError("expired", http.StatusGone) 30 | // ErrUnsupportedFormat unsupported format error 31 | ErrUnsupportedFormat = NewError("unsupported format", http.StatusNotAcceptable) 32 | // ErrMaxSizeExceeded maximum size exceeded error 33 | ErrMaxSizeExceeded = NewError("maximum size exceeded", http.StatusBadRequest) 34 | // ErrMaxResolutionExceeded maximum resolution exceeded error 35 | ErrMaxResolutionExceeded = NewError("maximum resolution exceeded", http.StatusUnprocessableEntity) 36 | // ErrTooManyRequests too many requests error 37 | ErrTooManyRequests = NewError("too many requests", http.StatusTooManyRequests) 38 | // ErrInternal internal error 39 | ErrInternal = NewError("internal error", http.StatusInternalServerError) 40 | ) 41 | 42 | const errPrefix = "imagor:" 43 | 44 | var errMsgRegexp = regexp.MustCompile(fmt.Sprintf("^%s ([0-9]+) (.*)$", errPrefix)) 45 | 46 | // ErrForward indicator passing imagorpath.Params to next processor 47 | type ErrForward struct { 48 | imagorpath.Params 49 | } 50 | 51 | // Error implements error 52 | func (p ErrForward) Error() string { 53 | return fmt.Sprintf("%s forward %s", errPrefix, imagorpath.GeneratePath(p.Params)) 54 | } 55 | 56 | // Error imagor error convention 57 | type Error struct { 58 | Message string `json:"message,omitempty"` 59 | Code int `json:"status,omitempty"` 60 | } 61 | 62 | type timeoutErr interface { 63 | Timeout() bool 64 | } 65 | 66 | // Error implements error 67 | func (e Error) Error() string { 68 | return fmt.Sprintf("%s %d %s", errPrefix, e.Code, e.Message) 69 | } 70 | 71 | // Timeout indicates if error is timeout 72 | func (e Error) Timeout() bool { 73 | return e.Code == http.StatusRequestTimeout || e.Code == http.StatusGatewayTimeout 74 | } 75 | 76 | // NewError creates imagor Error from message and status code 77 | func NewError(msg string, code int) Error { 78 | return Error{Message: msg, Code: code} 79 | } 80 | 81 | // NewErrorFromStatusCode creates imagor Error solely from status code 82 | func NewErrorFromStatusCode(code int) Error { 83 | return NewError(http.StatusText(code), code) 84 | } 85 | 86 | // WrapError wraps Go error into imagor Error 87 | func WrapError(err error) Error { 88 | if err == nil { 89 | return ErrInternal 90 | } 91 | if e, ok := err.(Error); ok { 92 | return e 93 | } 94 | if _, ok := err.(ErrForward); ok { 95 | // ErrForward till the end means no supported processor 96 | return ErrUnsupportedFormat 97 | } 98 | if e, ok := err.(timeoutErr); ok { 99 | if e.Timeout() { 100 | return ErrTimeout 101 | } 102 | } 103 | if errors.Is(err, context.DeadlineExceeded) { 104 | return ErrTimeout 105 | } 106 | if msg := err.Error(); errMsgRegexp.MatchString(msg) { 107 | if match := errMsgRegexp.FindStringSubmatch(msg); len(match) == 3 { 108 | code, _ := strconv.Atoi(match[1]) 109 | return NewError(match[2], code) 110 | } 111 | } 112 | msg := strings.Replace(err.Error(), "\n", "", -1) 113 | return NewError(msg, http.StatusInternalServerError) 114 | } 115 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package imagor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/cshum/imagor/imagorpath" 7 | "github.com/stretchr/testify/assert" 8 | "net" 9 | "net/http" 10 | "net/url" 11 | "testing" 12 | ) 13 | 14 | func TestWrapError(t *testing.T) { 15 | var err error 16 | var e Error 17 | 18 | assert.Equal(t, WrapError(nil), ErrInternal) 19 | 20 | assert.Equal(t, ErrMethodNotAllowed, WrapError(ErrMethodNotAllowed)) 21 | 22 | err = NewError("errorrrr", 167) 23 | assert.Equal(t, WrapError(errors.New(err.Error())), err) 24 | 25 | assert.Equal(t, ErrTimeout, WrapError(context.DeadlineExceeded)) 26 | 27 | assert.Equal(t, true, ErrTimeout.Timeout()) 28 | 29 | assert.Equal(t, ErrTimeout, WrapError(&url.Error{Err: context.DeadlineExceeded})) 30 | 31 | err = errors.New("asdfsdfsaf") 32 | e = WrapError(err) 33 | assert.Equal(t, 500, e.Code) 34 | assert.Contains(t, e.Error(), err.Error()) 35 | 36 | e = NewErrorFromStatusCode(403) 37 | assert.Equal(t, 403, e.Code) 38 | assert.Contains(t, e.Error(), http.StatusText(403)) 39 | 40 | err = &net.DNSError{IsTimeout: true} 41 | assert.Equal(t, ErrTimeout, WrapError(err)) 42 | 43 | err = ErrForward{imagorpath.Params{Width: 167, Height: 169, Image: "foo"}} 44 | assert.Equal(t, "imagor: forward 167x169/foo", err.Error()) 45 | assert.Equal(t, ErrUnsupportedFormat, WrapError(err)) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /examples/from_buffer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/imagorpath" 7 | "github.com/cshum/imagor/loader/httploader" 8 | "github.com/cshum/imagor/processor/vipsprocessor" 9 | "io" 10 | "net/http" 11 | "os" 12 | ) 13 | 14 | func main() { 15 | app := imagor.New( 16 | imagor.WithLoaders(httploader.New()), 17 | imagor.WithProcessors(vipsprocessor.NewProcessor()), 18 | ) 19 | ctx := context.Background() 20 | if err := app.Startup(ctx); err != nil { 21 | panic(err) 22 | } 23 | defer app.Shutdown(ctx) 24 | 25 | buf := downloadBytes("https://raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png") 26 | 27 | // serve via image buffer 28 | in := imagor.NewBlobFromBytes(buf) 29 | 30 | out, err := app.ServeBlob(ctx, in, imagorpath.Params{ 31 | Width: 500, 32 | Height: 500, 33 | FitIn: true, 34 | Filters: []imagorpath.Filter{ 35 | {"fill", "yellow"}, 36 | {"format", "jpg"}, 37 | }, 38 | }) 39 | if err != nil { 40 | panic(err) 41 | } 42 | reader, _, err := out.NewReader() 43 | if err != nil { 44 | panic(err) 45 | } 46 | defer reader.Close() 47 | file, err := os.Create("gopher.jpg") 48 | if err != nil { 49 | panic(err) 50 | } 51 | defer file.Close() 52 | if _, err := io.Copy(file, reader); err != nil { 53 | panic(err) 54 | } 55 | } 56 | 57 | func downloadBytes(urlpath string) []byte { 58 | resp, err := http.Get(urlpath) 59 | if err != nil { 60 | panic(err) 61 | } 62 | defer resp.Body.Close() 63 | buf, err := io.ReadAll(resp.Body) 64 | if err != nil { 65 | panic(err) 66 | } 67 | return buf 68 | } 69 | -------------------------------------------------------------------------------- /examples/from_file/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/imagorpath" 7 | "github.com/cshum/imagor/loader/httploader" 8 | "github.com/cshum/imagor/processor/vipsprocessor" 9 | "io" 10 | "net/http" 11 | "os" 12 | ) 13 | 14 | func main() { 15 | app := imagor.New( 16 | imagor.WithLoaders(httploader.New()), 17 | imagor.WithProcessors(vipsprocessor.NewProcessor()), 18 | ) 19 | ctx := context.Background() 20 | if err := app.Startup(ctx); err != nil { 21 | panic(err) 22 | } 23 | defer app.Shutdown(ctx) 24 | 25 | downloadFile("https://raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png", "gopher.png") 26 | 27 | // serve via file path 28 | in := imagor.NewBlobFromFile("gopher.png") 29 | 30 | out, err := app.ServeBlob(ctx, in, imagorpath.Params{ 31 | Width: 500, 32 | Height: 500, 33 | Smart: true, 34 | Filters: []imagorpath.Filter{ 35 | {"fill", "yellow"}, 36 | {"format", "jpg"}, 37 | }, 38 | }) 39 | if err != nil { 40 | panic(err) 41 | } 42 | reader, _, err := out.NewReader() 43 | if err != nil { 44 | panic(err) 45 | } 46 | defer reader.Close() 47 | file, err := os.Create("gopher.jpg") 48 | if err != nil { 49 | panic(err) 50 | } 51 | defer file.Close() 52 | if _, err := io.Copy(file, reader); err != nil { 53 | panic(err) 54 | } 55 | } 56 | 57 | func downloadFile(urlpath, filepath string) { 58 | resp, err := http.Get(urlpath) 59 | if err != nil { 60 | panic(err) 61 | } 62 | defer resp.Body.Close() 63 | file, err := os.Create(filepath) 64 | if err != nil { 65 | panic(err) 66 | } 67 | defer file.Close() 68 | if _, err := io.Copy(file, resp.Body); err != nil { 69 | panic(err) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/from_memory/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/imagorpath" 7 | "github.com/cshum/imagor/loader/httploader" 8 | "github.com/cshum/imagor/processor/vipsprocessor" 9 | "image" 10 | "io" 11 | "net/http" 12 | "os" 13 | 14 | _ "image/png" 15 | ) 16 | 17 | func main() { 18 | app := imagor.New( 19 | imagor.WithLoaders(httploader.New()), 20 | imagor.WithProcessors(vipsprocessor.NewProcessor()), 21 | ) 22 | ctx := context.Background() 23 | if err := app.Startup(ctx); err != nil { 24 | panic(err) 25 | } 26 | defer app.Shutdown(ctx) 27 | 28 | resp, err := http.Get("https://raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png") 29 | if err != nil { 30 | panic(err) 31 | } 32 | defer resp.Body.Close() 33 | img, _, err := image.Decode(resp.Body) 34 | if err != nil { 35 | panic(err) 36 | } 37 | nrgba := img.(*image.NRGBA) 38 | size := nrgba.Rect.Size() 39 | 40 | in := imagor.NewBlobFromMemory(nrgba.Pix, size.X, size.Y, 4) 41 | 42 | // serve via image path 43 | out, err := app.ServeBlob(ctx, in, imagorpath.Params{ 44 | Width: 500, 45 | Height: 500, 46 | HFlip: true, 47 | FitIn: true, 48 | Filters: []imagorpath.Filter{ 49 | {"format", "jpg"}, 50 | }, 51 | }) 52 | if err != nil { 53 | panic(err) 54 | } 55 | reader, _, err := out.NewReader() 56 | if err != nil { 57 | panic(err) 58 | } 59 | defer reader.Close() 60 | file, err := os.Create("gopher.jpg") 61 | if err != nil { 62 | panic(err) 63 | } 64 | defer file.Close() 65 | if _, err := io.Copy(file, reader); err != nil { 66 | panic(err) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/from_path/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/imagorpath" 7 | "github.com/cshum/imagor/loader/httploader" 8 | "github.com/cshum/imagor/processor/vipsprocessor" 9 | "io" 10 | "os" 11 | ) 12 | 13 | func main() { 14 | app := imagor.New( 15 | imagor.WithLoaders(httploader.New()), 16 | imagor.WithProcessors(vipsprocessor.NewProcessor()), 17 | ) 18 | ctx := context.Background() 19 | if err := app.Startup(ctx); err != nil { 20 | panic(err) 21 | } 22 | defer app.Shutdown(ctx) 23 | // serve via image path 24 | out, err := app.Serve(ctx, imagorpath.Params{ 25 | Image: "https://raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png", 26 | Width: 500, 27 | Height: 500, 28 | Smart: true, 29 | Filters: []imagorpath.Filter{ 30 | {"fill", "white"}, 31 | {"format", "jpg"}, 32 | }, 33 | }) 34 | if err != nil { 35 | panic(err) 36 | } 37 | reader, _, err := out.NewReader() 38 | if err != nil { 39 | panic(err) 40 | } 41 | defer reader.Close() 42 | file, err := os.Create("gopher.jpg") 43 | if err != nil { 44 | panic(err) 45 | } 46 | defer file.Close() 47 | if _, err := io.Copy(file, reader); err != nil { 48 | panic(err) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/from_reader/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/imagorpath" 7 | "github.com/cshum/imagor/loader/httploader" 8 | "github.com/cshum/imagor/processor/vipsprocessor" 9 | "io" 10 | "net/http" 11 | "os" 12 | "strconv" 13 | ) 14 | 15 | func main() { 16 | app := imagor.New( 17 | imagor.WithLoaders(httploader.New()), 18 | imagor.WithProcessors(vipsprocessor.NewProcessor()), 19 | ) 20 | ctx := context.Background() 21 | if err := app.Startup(ctx); err != nil { 22 | panic(err) 23 | } 24 | defer app.Shutdown(ctx) 25 | // serve via io.ReadCloser Blob 26 | in := imagor.NewBlob(func() (reader io.ReadCloser, size int64, err error) { 27 | var resp *http.Response 28 | if resp, err = http.Get("https://raw.githubusercontent.com/cshum/imagor/master/testdata/dancing-banana.gif"); err != nil { 29 | return 30 | } 31 | reader = resp.Body 32 | size, _ = strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) 33 | // known size via Content-Length header 34 | // size is optional; providing size enables better throughput and memory allocations 35 | return 36 | }) 37 | out, err := app.ServeBlob(ctx, in, imagorpath.Params{ 38 | Width: 200, 39 | Height: 150, 40 | FitIn: true, 41 | Filters: []imagorpath.Filter{ 42 | {"fill", "yellow"}, 43 | {"watermark", "https://raw.githubusercontent.com/cshum/imagor/master/testdata/gopher-front.png,repeat,bottom,0,40,40"}, 44 | }, 45 | }) 46 | if err != nil { 47 | panic(err) 48 | } 49 | reader, _, err := out.NewReader() 50 | if err != nil { 51 | panic(err) 52 | } 53 | defer reader.Close() 54 | file, err := os.Create("dancing-banana.gif") 55 | if err != nil { 56 | panic(err) 57 | } 58 | defer file.Close() 59 | if _, err := io.Copy(file, reader); err != nil { 60 | panic(err) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /examples/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cshum/imagor" 5 | "github.com/cshum/imagor/imagorpath" 6 | "github.com/cshum/imagor/loader/httploader" 7 | "github.com/cshum/imagor/processor/vipsprocessor" 8 | "github.com/cshum/imagor/server" 9 | "github.com/cshum/imagor/storage/filestorage" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func main() { 14 | logger := zap.Must(zap.NewProduction()) 15 | 16 | // create and run imagor server programmatically 17 | server.New( 18 | imagor.New( 19 | imagor.WithLogger(logger), 20 | imagor.WithUnsafe(true), 21 | imagor.WithProcessors(vipsprocessor.NewProcessor()), 22 | imagor.WithLoaders(httploader.New()), 23 | imagor.WithStorages(filestorage.New("./")), 24 | imagor.WithResultStorages(filestorage.New("./")), 25 | imagor.WithResultStoragePathStyle(imagorpath.SuffixResultStorageHasher), 26 | ), 27 | server.WithPort(8000), 28 | server.WithLogger(logger), 29 | ).Run() 30 | } 31 | -------------------------------------------------------------------------------- /examples/vips/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cshum/vipsgen/vips" 5 | "net/http" 6 | ) 7 | 8 | func main() { 9 | // manipulate images using libvips C bindings 10 | vips.Startup(nil) 11 | defer vips.Shutdown() 12 | 13 | // create source from io.ReadCloser 14 | resp, err := http.Get("https://raw.githubusercontent.com/cshum/imagor/master/testdata/dancing-banana.gif") 15 | if err != nil { 16 | panic(err) 17 | } 18 | source := vips.NewSource(resp.Body) 19 | defer source.Close() // source needs to remain available during the lifetime of image 20 | 21 | image, err := vips.NewImageFromSource(source, &vips.LoadOptions{N: -1}) 22 | if err != nil { 23 | panic(err) 24 | } 25 | defer image.Close() 26 | if err = image.ExtractAreaMultiPage(30, 40, 50, 70); err != nil { 27 | panic(err) 28 | } 29 | if err = image.Flatten(&vips.FlattenOptions{Background: []float64{0, 255, 255}}); err != nil { 30 | panic(err) 31 | } 32 | err = image.Gifsave("dancing-banana.gif", nil) 33 | if err != nil { 34 | panic(err) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /fanoutreader/README.md: -------------------------------------------------------------------------------- 1 | # fanoutreader 2 | 3 | fanoutreader allows fan out arbitrary number of reader streams concurrently from one data source with known total size, using channel and memory buffer. 4 | 5 | https://pkg.go.dev/github.com/cshum/imagor/fanoutreader 6 | 7 | ### Why? 8 | 9 | There are some scenarios you may want to fan out a reader stream to multiple writers. For example, reading from a HTTP request that writes to several cloud storages. 10 | 11 | Normally you can first download the file into a `[]byte` buffer if it fits inside memory. You may do that with `io.ReadAll`, or better `io.ReadFull` to avoid continuous memory allocations. When the bytes are fully loaded, it is then safe to write to multiple `io.Writer` concurrently. However, it means data needs to be fully loaded before proceeding to the consumers, which is not an optimal way of stream pipe. 12 | 13 | Here comes `io.TeeReader` and `io.MultiWriter` where you can mirror the reader content to a writer, or write to several writers in a row. This is great and it works perfectly, assuming if the writers always write at lighting speed and there is zero backpressure when consuming from the reader. 14 | 15 | However, in the real world of network I/O, slowdown exists and it may happen at any time. If the writer cannot consume at expected pace, it blocks, causing backpressure to the reader. This problem magnifies if `io.TeeReader` or `io.MultiWriter` are used, as the writers are sequential throughout the process. When any of the writer/consumer backpressure happens, it simply blocks all other writers/consumers from continuing, causing even further slowdowns. 16 | 17 | So what now? Is it possible to achieve both stream pipe and concurrency? This is where fanoutreader comes handy. fanoutreader achieves both stream pipe and concurrency by leveraging memory buffer and channels. So if the data size is known and can be fit inside memory, then fanoutreader can be used. 18 | 19 | fanoutreader is easy to use. Just wrap the `io.ReadCloser` source providing the size: 20 | ```go 21 | fanout := fanoutreader.New(source, size) 22 | ``` 23 | Then you can fan out any number of `io.ReadCloser`: 24 | ```go 25 | reader := fanout.NewReader() 26 | ``` 27 | and they will simply work as expected, concurrently. 28 | 29 | ### Example 30 | 31 | Example writing 10 files concurrently from single io.ReadCloser HTTP request. (Error handling are omitted for demo purpose only) 32 | 33 | ```go 34 | package main 35 | 36 | import ( 37 | "fmt" 38 | "github.com/cshum/imagor/fanoutreader" 39 | "io" 40 | "net/http" 41 | "os" 42 | "strconv" 43 | "sync" 44 | ) 45 | 46 | func main() { 47 | // http source 48 | resp, _ := http.DefaultClient.Get("https://raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png") 49 | size, _ := strconv.Atoi(resp.Header.Get("Content-Length")) // known size via Content-Length header 50 | fanout := fanoutreader.New(resp.Body, size) // create fan out from single reader source 51 | 52 | var wg sync.WaitGroup 53 | for i := 0; i < 10; i++ { 54 | wg.Add(1) 55 | go func(i int) { 56 | reader := fanout.NewReader() // fan out new reader 57 | defer reader.Close() 58 | file, _ := os.Create(fmt.Sprintf("gopher-%d.png", i)) 59 | defer file.Close() 60 | _, _ = io.Copy(file, reader) // read/write concurrently alongside other readers 61 | wg.Done() 62 | }(i) 63 | } 64 | wg.Wait() 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cshum/imagor 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | cloud.google.com/go/storage v1.55.0 7 | github.com/TheZeroSlave/zapsentry v1.23.0 8 | github.com/aws/aws-sdk-go v1.55.7 9 | github.com/cshum/vipsgen v1.1.0 10 | github.com/fsouza/fake-gcs-server v1.52.2 11 | github.com/getsentry/sentry-go v0.33.0 12 | github.com/johannesboyne/gofakes3 v0.0.0-20250402064820-d479899d8cbe 13 | github.com/peterbourgon/ff/v3 v3.4.0 14 | github.com/prometheus/client_golang v1.22.0 15 | github.com/rs/cors v1.11.1 16 | github.com/stretchr/testify v1.10.0 17 | go.uber.org/zap v1.27.0 18 | golang.org/x/image v0.27.0 19 | golang.org/x/sync v0.14.0 20 | ) 21 | 22 | require ( 23 | cel.dev/expr v0.24.0 // indirect 24 | cloud.google.com/go v0.121.2 // indirect 25 | cloud.google.com/go/auth v0.16.1 // indirect 26 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 27 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 28 | cloud.google.com/go/iam v1.5.2 // indirect 29 | cloud.google.com/go/monitoring v1.24.2 // indirect 30 | cloud.google.com/go/pubsub v1.49.0 // indirect 31 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect 32 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect 33 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect 34 | github.com/beorn7/perks v1.0.1 // indirect 35 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 36 | github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect 37 | github.com/davecgh/go-spew v1.1.1 // indirect 38 | github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect 39 | github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect 40 | github.com/felixge/httpsnoop v1.0.4 // indirect 41 | github.com/go-jose/go-jose/v4 v4.1.0 // indirect 42 | github.com/go-logr/logr v1.4.2 // indirect 43 | github.com/go-logr/stdr v1.2.2 // indirect 44 | github.com/google/renameio/v2 v2.0.0 // indirect 45 | github.com/google/s2a-go v0.1.9 // indirect 46 | github.com/google/uuid v1.6.0 // indirect 47 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 48 | github.com/googleapis/gax-go/v2 v2.14.2 // indirect 49 | github.com/gorilla/handlers v1.5.2 // indirect 50 | github.com/gorilla/mux v1.8.1 // indirect 51 | github.com/jmespath/go-jmespath v0.4.0 // indirect 52 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 53 | github.com/pkg/xattr v0.4.10 // indirect 54 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 55 | github.com/pmezard/go-difflib v1.0.0 // indirect 56 | github.com/prometheus/client_model v0.6.2 // indirect 57 | github.com/prometheus/common v0.64.0 // indirect 58 | github.com/prometheus/procfs v0.16.1 // indirect 59 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect 60 | github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect 61 | github.com/zeebo/errs v1.4.0 // indirect 62 | go.opencensus.io v0.24.0 // indirect 63 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 64 | go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect 65 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect 66 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 67 | go.opentelemetry.io/otel v1.36.0 // indirect 68 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 69 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect 70 | go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect 71 | go.opentelemetry.io/otel/trace v1.36.0 // indirect 72 | go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect 73 | go.uber.org/multierr v1.11.0 // indirect 74 | golang.org/x/crypto v0.38.0 // indirect 75 | golang.org/x/net v0.40.0 // indirect 76 | golang.org/x/oauth2 v0.30.0 // indirect 77 | golang.org/x/sys v0.33.0 // indirect 78 | golang.org/x/text v0.25.0 // indirect 79 | golang.org/x/time v0.11.0 // indirect 80 | golang.org/x/tools v0.33.0 // indirect 81 | google.golang.org/api v0.235.0 // indirect 82 | google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237 // indirect 83 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect 84 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 85 | google.golang.org/grpc v1.72.1 // indirect 86 | google.golang.org/protobuf v1.36.6 // indirect 87 | gopkg.in/yaml.v3 v3.0.1 // indirect 88 | ) 89 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: heroku/Dockerfile -------------------------------------------------------------------------------- /heroku/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM shumc/imagor:latest 2 | LABEL maintainer="Adrian Shum " -------------------------------------------------------------------------------- /imagorpath/README.md: -------------------------------------------------------------------------------- 1 | # imagorpath 2 | 3 | Parse and generate imagor endpoint using Go struct 4 | 5 | ```go 6 | import "github.com/cshum/imagor/imagorpath" 7 | 8 | ... 9 | 10 | func Test(t *testing.T) { 11 | params := imagorpath.Params{ 12 | Image: "raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png", 13 | FitIn: true, 14 | Width: 500, 15 | Height: 400, 16 | PaddingTop: 20, 17 | PaddingBottom: 20, 18 | Filters: imagorpath.Filters{ 19 | { 20 | Name: "fill", 21 | Args: "white", 22 | }, 23 | }, 24 | } 25 | 26 | // generate signed imagor endpoint from Params struct with secret 27 | path := imagorpath.Generate(params, imagorpath.NewDefaultSigner("mysecret")) 28 | 29 | assert.Equal(t, path, "OyGJyvfYJw8xNkYDmXU-4NPA2U0=/fit-in/500x400/0x20/filters:fill(white)/raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png") 30 | 31 | assert.Equal(t, 32 | // parse Params struct from signed imagor endpoint 33 | imagorpath.Parse(path), 34 | 35 | // Params include endpoint attributes with path and signed hash 36 | imagorpath.Params{ 37 | Path: "fit-in/500x400/0x20/filters:fill(white)/raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png", 38 | Hash: "OyGJyvfYJw8xNkYDmXU-4NPA2U0=", 39 | Image: "raw.githubusercontent.com/cshum/imagor/master/testdata/gopher.png", 40 | FitIn: true, 41 | Width: 500, 42 | Height: 400, 43 | PaddingTop: 20, 44 | PaddingBottom: 20, 45 | Filters: imagorpath.Filters{ 46 | { 47 | Name: "fill", 48 | Args: "white", 49 | }, 50 | }, 51 | }, 52 | ) 53 | } 54 | 55 | ``` -------------------------------------------------------------------------------- /imagorpath/generate.go: -------------------------------------------------------------------------------- 1 | package imagorpath 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // GeneratePath generate imagor path by Params struct 11 | func GeneratePath(p Params) string { 12 | var parts []string 13 | if p.Meta { 14 | parts = append(parts, "meta") 15 | } 16 | if p.Trim || (p.TrimBy == TrimByTopLeft || p.TrimBy == TrimByBottomRight) { 17 | trims := []string{"trim"} 18 | if p.TrimBy == TrimByBottomRight { 19 | trims = append(trims, "bottom-right") 20 | } 21 | if p.TrimTolerance > 0 { 22 | trims = append(trims, strconv.Itoa(p.TrimTolerance)) 23 | } 24 | parts = append(parts, strings.Join(trims, ":")) 25 | } 26 | if p.CropTop > 0 || p.CropRight > 0 || p.CropLeft > 0 || p.CropBottom > 0 { 27 | parts = append(parts, fmt.Sprintf( 28 | "%sx%s:%sx%s", 29 | strconv.FormatFloat(p.CropLeft, 'f', -1, 64), 30 | strconv.FormatFloat(p.CropTop, 'f', -1, 64), 31 | strconv.FormatFloat(p.CropRight, 'f', -1, 64), 32 | strconv.FormatFloat(p.CropBottom, 'f', -1, 64))) 33 | } 34 | if p.FitIn { 35 | parts = append(parts, "fit-in") 36 | } 37 | if p.Stretch { 38 | parts = append(parts, "stretch") 39 | } 40 | if p.HFlip || p.Width != 0 || p.VFlip || p.Height != 0 || 41 | p.PaddingLeft > 0 || p.PaddingTop > 0 { 42 | if p.Width < 0 { 43 | p.HFlip = !p.HFlip 44 | p.Width = -p.Width 45 | } 46 | if p.Height < 0 { 47 | p.VFlip = !p.VFlip 48 | p.Height = -p.Height 49 | } 50 | var hFlipStr, vFlipStr string 51 | if p.HFlip { 52 | hFlipStr = "-" 53 | } 54 | if p.VFlip { 55 | vFlipStr = "-" 56 | } 57 | parts = append(parts, fmt.Sprintf( 58 | "%s%dx%s%d", hFlipStr, p.Width, vFlipStr, p.Height)) 59 | } 60 | if p.PaddingLeft > 0 || p.PaddingTop > 0 || p.PaddingRight > 0 || p.PaddingBottom > 0 { 61 | if p.PaddingLeft == p.PaddingRight && p.PaddingTop == p.PaddingBottom { 62 | parts = append(parts, fmt.Sprintf("%dx%d", p.PaddingLeft, p.PaddingTop)) 63 | } else { 64 | parts = append(parts, fmt.Sprintf( 65 | "%dx%d:%dx%d", 66 | p.PaddingLeft, p.PaddingTop, 67 | p.PaddingRight, p.PaddingBottom)) 68 | } 69 | } 70 | if p.HAlign == HAlignLeft || p.HAlign == HAlignRight { 71 | parts = append(parts, p.HAlign) 72 | } 73 | if p.VAlign == VAlignTop || p.VAlign == VAlignBottom { 74 | parts = append(parts, p.VAlign) 75 | } 76 | if p.Smart { 77 | parts = append(parts, "smart") 78 | } 79 | if len(p.Filters) > 0 { 80 | var filters []string 81 | for _, f := range p.Filters { 82 | filters = append(filters, fmt.Sprintf("%s(%s)", f.Name, f.Args)) 83 | } 84 | parts = append(parts, "filters:"+strings.Join(filters, ":")) 85 | } 86 | if strings.Contains(p.Image, "?") || 87 | strings.HasPrefix(p.Image, "trim/") || 88 | strings.HasPrefix(p.Image, "meta/") || 89 | strings.HasPrefix(p.Image, "fit-in/") || 90 | strings.HasPrefix(p.Image, "stretch/") || 91 | strings.HasPrefix(p.Image, "top/") || 92 | strings.HasPrefix(p.Image, "left/") || 93 | strings.HasPrefix(p.Image, "right/") || 94 | strings.HasPrefix(p.Image, "bottom/") || 95 | strings.HasPrefix(p.Image, "center/") || 96 | strings.HasPrefix(p.Image, "smart/") { 97 | p.Image = url.QueryEscape(p.Image) 98 | } 99 | parts = append(parts, p.Image) 100 | return strings.Join(parts, "/") 101 | } 102 | 103 | // GenerateUnsafe generate unsafe imagor endpoint by Params struct 104 | func GenerateUnsafe(p Params) string { 105 | return Generate(p, nil) 106 | } 107 | 108 | // Generate imagor endpoint with signature by Params struct with signer 109 | func Generate(p Params, signer Signer) string { 110 | imgPath := GeneratePath(p) 111 | if signer != nil { 112 | return signer.Sign(imgPath) + "/" + imgPath 113 | } 114 | return "unsafe/" + imgPath 115 | } 116 | -------------------------------------------------------------------------------- /imagorpath/hasher.go: -------------------------------------------------------------------------------- 1 | package imagorpath 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // StorageHasher define image key for storage 11 | type StorageHasher interface { 12 | Hash(image string) string 13 | } 14 | 15 | // ResultStorageHasher define key for result storage 16 | type ResultStorageHasher interface { 17 | HashResult(p Params) string 18 | } 19 | 20 | // StorageHasherFunc StorageHasher handler func 21 | type StorageHasherFunc func(image string) string 22 | 23 | // Hash implements StorageHasher interface 24 | func (h StorageHasherFunc) Hash(image string) string { 25 | return h(image) 26 | } 27 | 28 | // ResultStorageHasherFunc ResultStorageHasher handler func 29 | type ResultStorageHasherFunc func(p Params) string 30 | 31 | // HashResult implements ResultStorageHasher interface 32 | func (h ResultStorageHasherFunc) HashResult(p Params) string { 33 | return h(p) 34 | } 35 | 36 | func hexDigestPath(path string) string { 37 | var digest = sha1.Sum([]byte(path)) 38 | var hash = hex.EncodeToString(digest[:]) 39 | return hash[:2] + "/" + hash[2:4] + "/" + hash[4:] 40 | } 41 | 42 | // DigestStorageHasher StorageHasher using SHA digest 43 | var DigestStorageHasher = StorageHasherFunc(hexDigestPath) 44 | 45 | // DigestResultStorageHasher ResultStorageHasher using SHA digest 46 | var DigestResultStorageHasher = ResultStorageHasherFunc(func(p Params) string { 47 | if p.Path == "" { 48 | p.Path = GeneratePath(p) 49 | } 50 | return hexDigestPath(p.Path) 51 | }) 52 | 53 | // SuffixResultStorageHasher ResultStorageHasher using storage path with digest suffix 54 | var SuffixResultStorageHasher = ResultStorageHasherFunc(func(p Params) string { 55 | if p.Path == "" { 56 | p.Path = GeneratePath(p) 57 | } 58 | var digest = sha1.Sum([]byte(p.Path)) 59 | var hash = "." + hex.EncodeToString(digest[:])[:20] 60 | var dotIdx = strings.LastIndex(p.Image, ".") 61 | var slashIdx = strings.LastIndex(p.Image, "/") 62 | if dotIdx > -1 && slashIdx < dotIdx { 63 | ext := p.Image[dotIdx:] 64 | if p.Meta { 65 | ext = ".json" 66 | } else { 67 | for _, filter := range p.Filters { 68 | if filter.Name == "format" { 69 | ext = "." + filter.Args 70 | } 71 | } 72 | } 73 | return p.Image[:dotIdx] + hash + ext // /abc/def.{digest}.jpg 74 | } 75 | return p.Image + hash // /abc/def.{digest} 76 | }) 77 | 78 | // SizeSuffixResultStorageHasher ResultStorageHasher using storage path with digest and size suffix 79 | var SizeSuffixResultStorageHasher = ResultStorageHasherFunc(func(p Params) string { 80 | if p.Path == "" { 81 | p.Path = GeneratePath(p) 82 | } 83 | var digest = sha1.Sum([]byte(p.Path)) 84 | var hash = "." + hex.EncodeToString(digest[:])[:20] 85 | if p.Width != 0 || p.Height != 0 { 86 | hash += "_" + strconv.Itoa(p.Width) + "x" + strconv.Itoa(p.Height) 87 | } 88 | var dotIdx = strings.LastIndex(p.Image, ".") 89 | var slashIdx = strings.LastIndex(p.Image, "/") 90 | if dotIdx > -1 && slashIdx < dotIdx { 91 | ext := p.Image[dotIdx:] 92 | if p.Meta { 93 | ext = ".json" 94 | } else { 95 | for _, filter := range p.Filters { 96 | if filter.Name == "format" { 97 | ext = "." + filter.Args 98 | } 99 | } 100 | } 101 | return p.Image[:dotIdx] + hash + ext // /abc/def.{digest}_{width}x{height}.jpg 102 | } 103 | return p.Image + hash // /abc/def.{digest}_{width}x{height} 104 | }) 105 | -------------------------------------------------------------------------------- /imagorpath/hasher_test.go: -------------------------------------------------------------------------------- 1 | package imagorpath 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestHasher(t *testing.T) { 10 | p := Parse("fit-in/16x17/foobar") 11 | assert.Equal(t, "d5/c2/804e5d81c475bee50f731db17ee613f43262", DigestResultStorageHasher.HashResult(p)) 12 | p.Path = "" 13 | assert.Equal(t, "d5/c2/804e5d81c475bee50f731db17ee613f43262", DigestResultStorageHasher.HashResult(p)) 14 | p = Parse("fit-in/16x17/foobar") 15 | assert.Equal(t, "foobar.d5c2804e5d81c475bee5", SuffixResultStorageHasher.HashResult(p)) 16 | assert.Equal(t, "foobar.d5c2804e5d81c475bee5_16x17", SizeSuffixResultStorageHasher.HashResult(p)) 17 | p.Path = "" 18 | assert.Equal(t, "foobar.d5c2804e5d81c475bee5", SuffixResultStorageHasher.HashResult(p)) 19 | p = Parse("17x19/smart/example.com/foobar") 20 | assert.Equal(t, "example.com/foobar.ddd349e092cda6d9c729", SuffixResultStorageHasher.HashResult(p)) 21 | assert.Equal(t, "example.com/foobar.ddd349e092cda6d9c729_17x19", SizeSuffixResultStorageHasher.HashResult(p)) 22 | p = Parse("smart/example.com/foobar") 23 | assert.Equal(t, "example.com/foobar.afa3503c0d76bc49eccd", SizeSuffixResultStorageHasher.HashResult(p)) 24 | assert.Equal(t, "example.com/foobar.afa3503c0d76bc49eccd", SuffixResultStorageHasher.HashResult(p)) 25 | p = Parse("166x169/top/foobar.jpg") 26 | assert.Equal(t, "foobar.45d8ebb31bd4ed80c26e.jpg", SuffixResultStorageHasher.HashResult(p)) 27 | assert.Equal(t, "foobar.45d8ebb31bd4ed80c26e_166x169.jpg", SizeSuffixResultStorageHasher.HashResult(p)) 28 | p.Path = "" 29 | assert.Equal(t, "foobar.45d8ebb31bd4ed80c26e.jpg", SuffixResultStorageHasher.HashResult(p)) 30 | } 31 | 32 | func TestSuffixResultStorageHasher(t *testing.T) { 33 | p := Params{ 34 | Smart: true, Width: 17, Height: 19, Image: "example.com/foobar.jpg", 35 | Filters: []Filter{{"format", "webp"}}, 36 | } 37 | fmt.Println(GeneratePath(p)) 38 | assert.Equal(t, "example.com/foobar.8aade9060badfcb289f9.webp", SuffixResultStorageHasher.HashResult(p)) 39 | assert.Equal(t, "example.com/foobar.8aade9060badfcb289f9_17x19.webp", SizeSuffixResultStorageHasher.HashResult(p)) 40 | 41 | p = Params{ 42 | Meta: true, 43 | Smart: true, Width: 17, Height: 19, Image: "example.com/foobar.jpg", 44 | } 45 | fmt.Println(GeneratePath(p)) 46 | assert.Equal(t, "example.com/foobar.d72ff6ef20ba41fa570c.json", SuffixResultStorageHasher.HashResult(p)) 47 | assert.Equal(t, "example.com/foobar.d72ff6ef20ba41fa570c_17x19.json", SizeSuffixResultStorageHasher.HashResult(p)) 48 | 49 | p = Params{ 50 | Meta: true, 51 | Smart: true, Width: 17, Height: 19, Image: "example.com/foobar.jpg", 52 | Filters: []Filter{{"format", "webp"}}, 53 | } 54 | fmt.Println(GeneratePath(p)) 55 | assert.Equal(t, "example.com/foobar.c80ab0faf85b35a140a8.json", SuffixResultStorageHasher.HashResult(p)) 56 | assert.Equal(t, "example.com/foobar.c80ab0faf85b35a140a8_17x19.json", SizeSuffixResultStorageHasher.HashResult(p)) 57 | } 58 | -------------------------------------------------------------------------------- /imagorpath/normalize.go: -------------------------------------------------------------------------------- 1 | package imagorpath 2 | 3 | import ( 4 | "path" 5 | "strings" 6 | ) 7 | 8 | const upperHex = "0123456789ABCDEF" 9 | 10 | // SafeChars safe chars for storage paths 11 | type SafeChars interface { 12 | // ShouldEscape indicates if char byte should be escaped 13 | ShouldEscape(c byte) bool 14 | } 15 | 16 | var defaultSafeChars = NewSafeChars("") 17 | 18 | // NewSafeChars create SafeChars from predefined set of string, or "--" for no-op 19 | func NewSafeChars(safechars string) SafeChars { 20 | if safechars == "--" { 21 | return NewNoopSafeChars() 22 | } 23 | s := &safeChars{safeChars: map[byte]bool{}} 24 | for _, c := range safechars { 25 | s.safeChars[byte(c)] = true 26 | s.hasCustom = true 27 | } 28 | return s 29 | } 30 | 31 | // NewNoopSafeChars create no-op SafeChars 32 | func NewNoopSafeChars() SafeChars { 33 | return &safeChars{noop: true} 34 | } 35 | 36 | type safeChars struct { 37 | hasCustom bool 38 | noop bool 39 | safeChars map[byte]bool 40 | } 41 | 42 | // ShouldEscape implements SafeChars interface 43 | func (s safeChars) ShouldEscape(c byte) bool { 44 | if s.noop { 45 | return false 46 | } 47 | // alphanum 48 | if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' { 49 | return false 50 | } 51 | switch c { 52 | case '/': // should not escape path segment 53 | return false 54 | case '-', '_', '.', '~': // Unreserved characters 55 | return false 56 | } 57 | if s.hasCustom && s.safeChars[c] { 58 | // safe chars from config 59 | return false 60 | } 61 | // Everything else must be escaped. 62 | return true 63 | } 64 | 65 | // extracted from url.escape plus allowing custom shouldEscape func 66 | func escape(s string, shouldEscape func(c byte) bool) string { 67 | spaceCount, hexCount := 0, 0 68 | for i := 0; i < len(s); i++ { 69 | c := s[i] 70 | if c == ' ' { 71 | spaceCount++ 72 | } else if shouldEscape(c) { 73 | hexCount++ 74 | } 75 | } 76 | 77 | if spaceCount == 0 && hexCount == 0 { 78 | return s 79 | } 80 | 81 | var buf [64]byte 82 | var t []byte 83 | 84 | required := len(s) + 2*hexCount 85 | if required <= len(buf) { 86 | t = buf[:required] 87 | } else { 88 | t = make([]byte, required) 89 | } 90 | 91 | if hexCount == 0 && shouldEscape(' ') { 92 | copy(t, s) 93 | for i := 0; i < len(s); i++ { 94 | if s[i] == ' ' { 95 | t[i] = '+' 96 | } 97 | } 98 | return string(t) 99 | } 100 | 101 | j := 0 102 | for i := 0; i < len(s); i++ { 103 | c := s[i] 104 | if shouldEscape(c) { 105 | if c != ' ' { 106 | t[j] = '%' 107 | t[j+1] = upperHex[c>>4] 108 | t[j+2] = upperHex[c&15] 109 | j += 3 110 | } else { 111 | t[j] = '+' 112 | j++ 113 | } 114 | } else { 115 | t[j] = s[i] 116 | j++ 117 | } 118 | } 119 | return string(t) 120 | } 121 | 122 | var breaksCleaner = strings.NewReplacer( 123 | "\r\n", "", 124 | "\r", "", 125 | "\n", "", 126 | "\v", "", 127 | "\f", "", 128 | "\u0085", "", 129 | "\u2028", "", 130 | "\u2029", "", 131 | ) 132 | 133 | // Normalize imagor path to be file path friendly, 134 | // optional escapeByte func for custom SafeChars 135 | func Normalize(image string, safeChars SafeChars) string { 136 | image = path.Clean(image) 137 | image = breaksCleaner.Replace(image) 138 | image = strings.Trim(image, "/") 139 | if safeChars == nil { 140 | return escape(image, defaultSafeChars.ShouldEscape) 141 | } 142 | return escape(image, safeChars.ShouldEscape) 143 | } 144 | -------------------------------------------------------------------------------- /imagorpath/params.go: -------------------------------------------------------------------------------- 1 | package imagorpath 2 | 3 | const ( 4 | // TrimByTopLeft trim by top-left keyword 5 | TrimByTopLeft = "top-left" 6 | // TrimByBottomRight trim by bottom-right keyword 7 | TrimByBottomRight = "bottom-right" 8 | // HAlignLeft horizontal align left keyword 9 | HAlignLeft = "left" 10 | // HAlignRight horizontal align right keyword 11 | HAlignRight = "right" 12 | // VAlignTop vertical align top keyword 13 | VAlignTop = "top" 14 | // VAlignBottom vertical align bottom keyword 15 | VAlignBottom = "bottom" 16 | ) 17 | 18 | // Filters a slice of Filter 19 | type Filters []Filter 20 | 21 | // Params image endpoint parameters 22 | type Params struct { 23 | Params bool `json:"-"` 24 | Path string `json:"path,omitempty"` 25 | Image string `json:"image,omitempty"` 26 | Unsafe bool `json:"unsafe,omitempty"` 27 | Hash string `json:"hash,omitempty"` 28 | Meta bool `json:"meta,omitempty"` 29 | Trim bool `json:"trim,omitempty"` 30 | TrimBy string `json:"trim_by,omitempty"` 31 | TrimTolerance int `json:"trim_tolerance,omitempty"` 32 | CropLeft float64 `json:"crop_left,omitempty"` 33 | CropTop float64 `json:"crop_top,omitempty"` 34 | CropRight float64 `json:"crop_right,omitempty"` 35 | CropBottom float64 `json:"crop_bottom,omitempty"` 36 | FitIn bool `json:"fit_in,omitempty"` 37 | Stretch bool `json:"stretch,omitempty"` 38 | Width int `json:"width,omitempty"` 39 | Height int `json:"height,omitempty"` 40 | PaddingLeft int `json:"padding_left,omitempty"` 41 | PaddingTop int `json:"padding_top,omitempty"` 42 | PaddingRight int `json:"padding_right,omitempty"` 43 | PaddingBottom int `json:"padding_bottom,omitempty"` 44 | HFlip bool `json:"h_flip,omitempty"` 45 | VFlip bool `json:"v_flip,omitempty"` 46 | HAlign string `json:"h_align,omitempty"` 47 | VAlign string `json:"v_align,omitempty"` 48 | Smart bool `json:"smart,omitempty"` 49 | Filters Filters `json:"filters,omitempty"` 50 | } 51 | 52 | // Filter imagor endpoint filter 53 | type Filter struct { 54 | Name string `json:"name,omitempty"` 55 | Args string `json:"args,omitempty"` 56 | } 57 | -------------------------------------------------------------------------------- /imagorpath/signer.go: -------------------------------------------------------------------------------- 1 | package imagorpath 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "encoding/base64" 7 | "hash" 8 | ) 9 | 10 | // Signer imagor URL signature signer 11 | type Signer interface { 12 | Sign(path string) string 13 | } 14 | 15 | // NewDefaultSigner default signer using SHA1 with secret 16 | func NewDefaultSigner(secret string) Signer { 17 | return NewHMACSigner(sha1.New, 0, secret) 18 | } 19 | 20 | // NewHMACSigner custom HMAC alg signer with secret and string length based truncate 21 | func NewHMACSigner(alg func() hash.Hash, truncate int, secret string) Signer { 22 | return &hmacSigner{ 23 | alg: alg, 24 | truncate: truncate, 25 | secret: []byte(secret), 26 | } 27 | } 28 | 29 | type hmacSigner struct { 30 | alg func() hash.Hash 31 | truncate int 32 | secret []byte 33 | } 34 | 35 | func (s *hmacSigner) Sign(path string) string { 36 | h := hmac.New(s.alg, s.secret) 37 | h.Write([]byte(path)) 38 | sig := base64.URLEncoding.EncodeToString(h.Sum(nil)) 39 | if s.truncate > 0 && len(sig) > s.truncate { 40 | return sig[:s.truncate] 41 | } 42 | return sig 43 | } 44 | -------------------------------------------------------------------------------- /loader/httploader/timeout_test.go: -------------------------------------------------------------------------------- 1 | package httploader 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/cshum/imagor" 7 | "github.com/stretchr/testify/assert" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func jsonStr(v interface{}) string { 16 | buf, _ := json.Marshal(v) 17 | return string(buf) 18 | } 19 | 20 | func TestWithLoadTimeout(t *testing.T) { 21 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | if strings.Contains(r.URL.String(), "sleep") { 23 | time.Sleep(time.Millisecond * 50) 24 | } 25 | w.Header().Set("Content-Type", "image/jpeg") 26 | _, _ = w.Write([]byte("ok")) 27 | })) 28 | defer ts.Close() 29 | 30 | tests := []struct { 31 | name string 32 | app *imagor.Imagor 33 | }{ 34 | { 35 | name: "load timeout", 36 | app: imagor.New( 37 | imagor.WithUnsafe(true), 38 | imagor.WithLoadTimeout(time.Millisecond*10), 39 | imagor.WithLoaders(New()), 40 | ), 41 | }, 42 | { 43 | name: "request timeout", 44 | app: imagor.New( 45 | imagor.WithUnsafe(true), 46 | imagor.WithRequestTimeout(time.Millisecond*10), 47 | imagor.WithLoaders(New()), 48 | ), 49 | }, 50 | { 51 | name: "load timeout > request timeout", 52 | app: imagor.New( 53 | imagor.WithUnsafe(true), 54 | imagor.WithLoadTimeout(time.Millisecond*10), 55 | imagor.WithRequestTimeout(time.Millisecond*100), 56 | imagor.WithLoaders(New()), 57 | ), 58 | }, 59 | { 60 | name: "load timeout < request timeout", 61 | app: imagor.New( 62 | imagor.WithUnsafe(true), 63 | imagor.WithLoadTimeout(time.Millisecond*100), 64 | imagor.WithRequestTimeout(time.Millisecond*10), 65 | imagor.WithLoaders(New()), 66 | ), 67 | }, 68 | } 69 | for _, tt := range tests { 70 | t.Run("ok", func(t *testing.T) { 71 | w := httptest.NewRecorder() 72 | tt.app.ServeHTTP(w, httptest.NewRequest( 73 | http.MethodGet, fmt.Sprintf("https://example.com/unsafe/%s", ts.URL), nil)) 74 | assert.Equal(t, 200, w.Code) 75 | assert.Equal(t, w.Body.String(), "ok") 76 | }) 77 | t.Run("timeout", func(t *testing.T) { 78 | w := httptest.NewRecorder() 79 | tt.app.ServeHTTP(w, httptest.NewRequest( 80 | http.MethodGet, fmt.Sprintf("https://example.com/unsafe/%s/sleep", ts.URL), nil)) 81 | assert.Equal(t, http.StatusRequestTimeout, w.Code) 82 | assert.Equal(t, jsonStr(imagor.ErrTimeout), w.Body.String()) 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /loader/httploader/util.go: -------------------------------------------------------------------------------- 1 | package httploader 2 | 3 | import ( 4 | "math/rand" 5 | "net/http" 6 | "net/url" 7 | "path" 8 | "strings" 9 | ) 10 | 11 | func randomProxyFunc(proxyURLs, hosts string) func(*http.Request) (*url.URL, error) { 12 | var urls []*url.URL 13 | var allowedSources []AllowedSource 14 | for _, split := range strings.Split(proxyURLs, ",") { 15 | if u, err := url.Parse(strings.TrimSpace(split)); err == nil { 16 | urls = append(urls, u) 17 | } 18 | } 19 | ln := len(urls) 20 | for _, host := range strings.Split(hosts, ",") { 21 | host = strings.TrimSpace(host) 22 | if len(host) > 0 { 23 | allowedSources = append(allowedSources, NewHostPatternAllowedSource(host)) 24 | } 25 | } 26 | return func(r *http.Request) (u *url.URL, err error) { 27 | if len(urls) == 0 { 28 | return 29 | } 30 | if !isURLAllowed(r.URL, allowedSources) { 31 | return 32 | } 33 | u = urls[rand.Intn(ln)] 34 | return 35 | } 36 | } 37 | 38 | func isURLAllowed(u *url.URL, allowedSources []AllowedSource) bool { 39 | if len(allowedSources) == 0 { 40 | return true 41 | } 42 | for _, source := range allowedSources { 43 | if source.Match(u) { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | 50 | func parseContentType(contentType string) string { 51 | idx := strings.Index(contentType, ";") 52 | if idx == -1 { 53 | idx = len(contentType) 54 | } 55 | return strings.TrimSpace(strings.ToLower(contentType[0:idx])) 56 | } 57 | 58 | func validateContentType(contentType string, accepts []string) bool { 59 | if len(accepts) == 0 { 60 | return true 61 | } 62 | contentType = parseContentType(contentType) 63 | for _, accept := range accepts { 64 | if ok, err := path.Match(accept, contentType); ok && err == nil { 65 | return true 66 | } 67 | } 68 | return false 69 | } 70 | -------------------------------------------------------------------------------- /metrics/prometheusmetrics/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheusmetrics 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | var ( 13 | httpRequestDuration = prometheus.NewHistogramVec( 14 | prometheus.HistogramOpts{ 15 | Name: "http_request_duration_seconds", 16 | Help: "A histogram of latencies for requests", 17 | }, 18 | []string{"code", "method"}, 19 | ) 20 | ) 21 | 22 | // PrometheusMetrics wraps the Service with additional http and app lifecycle handling 23 | type PrometheusMetrics struct { 24 | http.Server 25 | 26 | Path string 27 | Logger *zap.Logger 28 | } 29 | 30 | // New create new metrics PrometheusMetrics 31 | func New(options ...Option) *PrometheusMetrics { 32 | s := &PrometheusMetrics{ 33 | Path: "/", 34 | Logger: zap.NewNop(), 35 | } 36 | for _, option := range options { 37 | option(s) 38 | } 39 | if s.Path != "" && s.Path != "/" { 40 | mux := http.NewServeMux() 41 | mux.Handle(s.Path, promhttp.Handler()) 42 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 43 | http.Redirect(w, r, s.Path, http.StatusPermanentRedirect) 44 | }) 45 | s.Handler = mux 46 | } else { 47 | s.Handler = promhttp.Handler() 48 | } 49 | return s 50 | } 51 | 52 | // Startup prometheus metrics server 53 | func (s *PrometheusMetrics) Startup(_ context.Context) error { 54 | if err := prometheus.Register(httpRequestDuration); err != nil { 55 | return err 56 | } 57 | 58 | go func() { 59 | if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { 60 | s.Logger.Fatal("prometheus listen", zap.Error(err)) 61 | } 62 | }() 63 | s.Logger.Info("prometheus listen", zap.String("addr", s.Addr), zap.String("path", s.Path)) 64 | return nil 65 | } 66 | 67 | // Handle prometheus http middleware handler 68 | func (s *PrometheusMetrics) Handle(next http.Handler) http.Handler { 69 | return promhttp.InstrumentHandlerDuration(httpRequestDuration, next) 70 | } 71 | 72 | // Option PrometheusMetrics option 73 | type Option func(s *PrometheusMetrics) 74 | 75 | // WithAddr with server and port option 76 | func WithAddr(addr string) Option { 77 | return func(s *PrometheusMetrics) { 78 | s.Addr = addr 79 | } 80 | } 81 | 82 | // WithPath with path option 83 | func WithPath(path string) Option { 84 | return func(s *PrometheusMetrics) { 85 | s.Path = path 86 | } 87 | } 88 | 89 | // WithLogger with logger option 90 | func WithLogger(logger *zap.Logger) Option { 91 | return func(s *PrometheusMetrics) { 92 | if logger != nil { 93 | s.Logger = logger 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /metrics/prometheusmetrics/prometheus_test.go: -------------------------------------------------------------------------------- 1 | package prometheusmetrics 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func TestWithOption(t *testing.T) { 13 | t.Run("default options", func(t *testing.T) { 14 | v := New() 15 | assert.Empty(t, v.Addr) 16 | assert.Equal(t, v.Path, "/") 17 | assert.NotNil(t, v.Logger) 18 | }) 19 | 20 | t.Run("options", func(t *testing.T) { 21 | l := &zap.Logger{} 22 | v := New( 23 | WithAddr("domain.example.com:1111"), 24 | WithPath("/myprom"), 25 | WithLogger(l), 26 | ) 27 | assert.Equal(t, "/myprom", v.Path) 28 | assert.Equal(t, "domain.example.com:1111", v.Addr) 29 | assert.Equal(t, &l, &v.Logger) 30 | w := httptest.NewRecorder() 31 | v.Handler.ServeHTTP(w, httptest.NewRequest( 32 | http.MethodGet, "https://example.com/", nil)) 33 | assert.Equal(t, http.StatusPermanentRedirect, w.Code) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /processor/vipsprocessor/context.go: -------------------------------------------------------------------------------- 1 | package vipsprocessor 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type contextRefKey struct{} 8 | 9 | type contextRef struct { 10 | cbs []func() 11 | Rotate90 bool 12 | } 13 | 14 | func (r *contextRef) Defer(cb func()) { 15 | r.cbs = append(r.cbs, cb) 16 | } 17 | 18 | func (r *contextRef) Done() { 19 | for _, cb := range r.cbs { 20 | cb() 21 | } 22 | r.cbs = nil 23 | } 24 | 25 | // withContext with callback tracking 26 | func withContext(ctx context.Context) context.Context { 27 | return context.WithValue(ctx, contextRefKey{}, &contextRef{}) 28 | } 29 | 30 | // contextDefer context add func for callback tracking for callback gc 31 | func contextDefer(ctx context.Context, cb func()) { 32 | ctx.Value(contextRefKey{}).(*contextRef).Defer(cb) 33 | } 34 | 35 | // contextDone closes all image refs that are being tracked through the context 36 | func contextDone(ctx context.Context) { 37 | ctx.Value(contextRefKey{}).(*contextRef).Done() 38 | } 39 | 40 | func setRotate90(ctx context.Context) { 41 | if r, ok := ctx.Value(contextRefKey{}).(*contextRef); ok { 42 | r.Rotate90 = !r.Rotate90 43 | } 44 | } 45 | 46 | func isRotate90(ctx context.Context) bool { 47 | if r, ok := ctx.Value(contextRefKey{}).(*contextRef); ok { 48 | return r.Rotate90 49 | } 50 | return false 51 | } 52 | -------------------------------------------------------------------------------- /processor/vipsprocessor/exif.go: -------------------------------------------------------------------------------- 1 | package vipsprocessor 2 | 3 | import "C" 4 | import ( 5 | "strings" 6 | ) 7 | 8 | func exifStringShort(s string) string { 9 | i := strings.Index(s, " (") 10 | if i > -1 { 11 | return s[:i] 12 | } 13 | return s 14 | } 15 | 16 | func extractExif(rawExif map[string]string) map[string]string { 17 | var exif = map[string]string{} 18 | for tag, value := range rawExif { 19 | if len(tag) < 10 { 20 | continue 21 | } 22 | name := tag[10:] 23 | value = strings.TrimSpace(exifStringShort(value)) 24 | if value == "" { 25 | continue 26 | } 27 | exif[name] = value 28 | } 29 | return exif 30 | } 31 | -------------------------------------------------------------------------------- /processor/vipsprocessor/fallback.go: -------------------------------------------------------------------------------- 1 | package vipsprocessor 2 | 3 | import ( 4 | "github.com/cshum/imagor" 5 | "github.com/cshum/vipsgen/vips" 6 | "golang.org/x/image/bmp" 7 | "image" 8 | "image/draw" 9 | "io" 10 | ) 11 | 12 | // FallbackFunc vips.Image fallback handler when vips.NewImageFromSource failed 13 | type FallbackFunc func(blob *imagor.Blob, options *vips.LoadOptions) (*vips.Image, error) 14 | 15 | // bufferFallbackFunc load image from buffer FallbackFunc 16 | func bufferFallbackFunc(blob *imagor.Blob, options *vips.LoadOptions) (*vips.Image, error) { 17 | buf, err := blob.ReadAll() 18 | if err != nil { 19 | return nil, err 20 | } 21 | return vips.NewImageFromBuffer(buf, options) 22 | } 23 | 24 | func estimateMaxBMPFileSize(maxResolution int64) int64 { 25 | const ( 26 | bmpHeaderSize = 54 27 | bytesPerPixel = 4 // 32-bit RGBA (worst case) 28 | safetyMargin = 1.2 // 20% buffer 29 | ) 30 | return int64(float64(bmpHeaderSize+maxResolution*bytesPerPixel) * safetyMargin) 31 | } 32 | 33 | func (v *Processor) loadImageFromBMP(r io.Reader) (*vips.Image, error) { 34 | img, err := bmp.Decode(r) 35 | if err != nil { 36 | return nil, err 37 | } 38 | rect := img.Bounds() 39 | size := rect.Size() 40 | if !v.Unlimited && (size.X > v.MaxWidth || size.Y > v.MaxHeight || size.X*size.Y > v.MaxResolution) { 41 | return nil, imagor.ErrMaxResolutionExceeded 42 | } 43 | rgba, ok := img.(*image.RGBA) 44 | if !ok { 45 | rgba = image.NewRGBA(rect) 46 | draw.Draw(rgba, rect, img, rect.Min, draw.Src) 47 | } 48 | return vips.NewImageFromMemory(rgba.Pix, size.X, size.Y, 4) 49 | } 50 | 51 | func (v *Processor) bmpFallbackFunc(blob *imagor.Blob, _ *vips.LoadOptions) (*vips.Image, error) { 52 | if blob.BlobType() == imagor.BlobTypeBMP { 53 | if blob.Size() > estimateMaxBMPFileSize(int64(v.MaxResolution)) { 54 | return nil, imagor.ErrMaxResolutionExceeded 55 | } 56 | r, _, err := blob.NewReader() 57 | if err != nil { 58 | return nil, err 59 | } 60 | defer func() { 61 | _ = r.Close() 62 | }() 63 | return v.loadImageFromBMP(r) 64 | } else { 65 | return nil, imagor.ErrUnsupportedFormat 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /processor/vipsprocessor/option.go: -------------------------------------------------------------------------------- 1 | package vipsprocessor 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "strings" 6 | ) 7 | 8 | // Option Processor option 9 | type Option func(v *Processor) 10 | 11 | // WithFilter with filer option of name and FilterFunc pair 12 | func WithFilter(name string, filter FilterFunc) Option { 13 | return func(v *Processor) { 14 | v.Filters[name] = filter 15 | } 16 | } 17 | 18 | // WithDisableBlur with disable blur option 19 | func WithDisableBlur(disabled bool) Option { 20 | return func(v *Processor) { 21 | v.DisableBlur = disabled 22 | } 23 | } 24 | 25 | // WithDisableFilters with disable filters option 26 | func WithDisableFilters(filters ...string) Option { 27 | return func(v *Processor) { 28 | for _, raw := range filters { 29 | splits := strings.Split(raw, ",") 30 | for _, name := range splits { 31 | name = strings.TrimSpace(name) 32 | if len(name) > 0 { 33 | v.DisableFilters = append(v.DisableFilters, name) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | // WithMozJPEG with MozJPEG option. Require MozJPEG to be installed 41 | func WithMozJPEG(enabled bool) Option { 42 | return func(v *Processor) { 43 | v.MozJPEG = enabled 44 | } 45 | } 46 | 47 | // WithStripMetadata with strip all metadata from image option 48 | func WithStripMetadata(enabled bool) Option { 49 | return func(v *Processor) { 50 | v.StripMetadata = enabled 51 | } 52 | } 53 | 54 | // WithAvifSpeed with avif speed option 55 | func WithAvifSpeed(avifSpeed int) Option { 56 | return func(v *Processor) { 57 | if avifSpeed >= 0 && avifSpeed <= 9 { 58 | v.AvifSpeed = avifSpeed 59 | } 60 | } 61 | } 62 | 63 | // WithMaxFilterOps with maximum number of filter operations option 64 | func WithMaxFilterOps(num int) Option { 65 | return func(v *Processor) { 66 | if num != 0 { 67 | v.MaxFilterOps = num 68 | } 69 | } 70 | } 71 | 72 | // WithMaxAnimationFrames with maximum count of animation frames option 73 | func WithMaxAnimationFrames(num int) Option { 74 | return func(v *Processor) { 75 | if num != 0 { 76 | v.MaxAnimationFrames = num 77 | } 78 | } 79 | } 80 | 81 | // WithConcurrency with libvips concurrency option 82 | func WithConcurrency(num int) Option { 83 | return func(v *Processor) { 84 | if num != 0 { 85 | v.Concurrency = num 86 | } 87 | } 88 | } 89 | 90 | // WithMaxCacheFiles with libvips max cache files option 91 | func WithMaxCacheFiles(num int) Option { 92 | return func(v *Processor) { 93 | if num > 0 { 94 | v.MaxCacheFiles = num 95 | } 96 | } 97 | } 98 | 99 | // WithMaxCacheSize with libvips max cache size option 100 | func WithMaxCacheSize(num int) Option { 101 | return func(v *Processor) { 102 | if num > 0 { 103 | v.MaxCacheSize = num 104 | } 105 | } 106 | } 107 | 108 | // WithMaxCacheMem with libvips max cache mem option 109 | func WithMaxCacheMem(num int) Option { 110 | return func(v *Processor) { 111 | if num > 0 { 112 | v.MaxCacheMem = num 113 | } 114 | } 115 | } 116 | 117 | // WithLogger with logger option 118 | func WithLogger(logger *zap.Logger) Option { 119 | return func(v *Processor) { 120 | if logger != nil { 121 | v.Logger = logger 122 | } 123 | } 124 | } 125 | 126 | // WithDebug with debug option 127 | func WithDebug(debug bool) Option { 128 | return func(v *Processor) { 129 | v.Debug = debug 130 | } 131 | } 132 | 133 | // WithMaxWidth with maximum width option 134 | func WithMaxWidth(width int) Option { 135 | return func(v *Processor) { 136 | if width > 0 { 137 | v.MaxWidth = width 138 | } 139 | } 140 | } 141 | 142 | // WithMaxHeight with maximum height option 143 | func WithMaxHeight(height int) Option { 144 | return func(v *Processor) { 145 | if height > 0 { 146 | v.MaxHeight = height 147 | } 148 | } 149 | } 150 | 151 | // WithMaxResolution with maximum resolution option 152 | func WithMaxResolution(res int) Option { 153 | return func(v *Processor) { 154 | if res > 0 { 155 | v.MaxResolution = res 156 | } 157 | } 158 | } 159 | 160 | // WithForceBmpFallback force with BMP fallback 161 | func WithForceBmpFallback() Option { 162 | return func(v *Processor) { 163 | v.FallbackFunc = v.bmpFallbackFunc 164 | } 165 | } 166 | 167 | // WithUnlimited with unlimited option that remove all denial of service limits 168 | func WithUnlimited(unlimited bool) Option { 169 | return func(v *Processor) { 170 | v.Unlimited = unlimited 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /processor/vipsprocessor/option_test.go: -------------------------------------------------------------------------------- 1 | package vipsprocessor 2 | 3 | import ( 4 | "context" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/vipsgen/vips" 7 | "github.com/stretchr/testify/assert" 8 | "runtime" 9 | "testing" 10 | ) 11 | 12 | func TestWithOption(t *testing.T) { 13 | t.Run("options", func(t *testing.T) { 14 | v := NewProcessor( 15 | WithConcurrency(2), 16 | WithMaxFilterOps(167), 17 | WithMaxCacheSize(500), 18 | WithMaxCacheMem(501), 19 | WithMaxCacheFiles(10), 20 | WithMaxWidth(999), 21 | WithMaxHeight(998), 22 | WithMaxResolution(1666667), 23 | WithMozJPEG(true), 24 | WithAvifSpeed(9), 25 | WithStripMetadata(true), 26 | WithDebug(true), 27 | WithMaxAnimationFrames(3), 28 | WithDisableFilters("rgb", "fill, watermark"), 29 | WithUnlimited(true), 30 | WithForceBmpFallback(), 31 | WithFilter("noop", func(ctx context.Context, img *vips.Image, load imagor.LoadFunc, args ...string) (err error) { 32 | return nil 33 | }), 34 | ) 35 | assert.Equal(t, 2, v.Concurrency) 36 | assert.Equal(t, 167, v.MaxFilterOps) 37 | assert.Equal(t, 500, v.MaxCacheSize) 38 | assert.Equal(t, 501, v.MaxCacheMem) 39 | assert.Equal(t, 10, v.MaxCacheFiles) 40 | assert.Equal(t, 999, v.MaxWidth) 41 | assert.Equal(t, 998, v.MaxHeight) 42 | assert.Equal(t, 1666667, v.MaxResolution) 43 | assert.Equal(t, 3, v.MaxAnimationFrames) 44 | assert.Equal(t, true, v.MozJPEG) 45 | assert.Equal(t, true, v.StripMetadata) 46 | assert.Equal(t, true, v.Unlimited) 47 | assert.Equal(t, 9, v.AvifSpeed) 48 | assert.Equal(t, []string{"rgb", "fill", "watermark"}, v.DisableFilters) 49 | assert.NotNil(t, v.FallbackFunc) 50 | 51 | }) 52 | t.Run("edge options", func(t *testing.T) { 53 | v := NewProcessor( 54 | WithConcurrency(-1), 55 | ) 56 | assert.Equal(t, runtime.NumCPU(), v.Concurrency) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /seekstream/README.md: -------------------------------------------------------------------------------- 1 | # seekstream 2 | 3 | seekstream allows seeking on non-seekable `io.ReadCloser` source by buffering read data using memory or temp file. 4 | 5 | ```go 6 | var source io.ReadCloser // non-seekable 7 | var buffer seekstream.Buffer 8 | ... 9 | var rs io.ReadSeekCloser = seekstream.New(source, buffer) // seekable 10 | ``` 11 | 12 | ## MemoryBuffer 13 | 14 | Use `NewMemoryBuffer(size)` if total size is known and can be fit inside memory: 15 | 16 | ```go 17 | package main 18 | 19 | import ( 20 | "github.com/cshum/imagor/seekstream" 21 | ... 22 | ) 23 | 24 | func Test(t *testing.T) { 25 | source := io.NopCloser(bytes.NewBuffer([]byte("0123456789"))) 26 | 27 | rs := seekstream.New(source, seekstream.NewMemoryBuffer(10)) 28 | defer rs.Close() 29 | 30 | b := make([]byte, 4) 31 | _, _ = rs.Read(b) 32 | assert.Equal(t, "0123", string(b)) 33 | 34 | b = make([]byte, 3) 35 | _, _ = rs.Seek(-2, io.SeekCurrent) 36 | _, _ = rs.Read(b) 37 | assert.Equal(t, "234", string(b)) 38 | 39 | b = make([]byte, 4) 40 | _, _ = rs.Seek(-5, io.SeekEnd) 41 | _, _ = rs.Read(b) 42 | assert.Equal(t, "5678", string(b)) 43 | } 44 | ``` 45 | 46 | ## TempFileBuffer 47 | 48 | Use `NewTempFileBuffer(dir, pattern)` if total size is not known or does not fit inside memory: 49 | 50 | ```go 51 | package main 52 | 53 | import ( 54 | "github.com/cshum/imagor/seekstream" 55 | ... 56 | ) 57 | 58 | func Test(t *testing.T) { 59 | source := io.NopCloser(bytes.NewBuffer([]byte("0123456789"))) 60 | 61 | buffer, err := seekstream.NewTempFileBuffer("", "seekstream-") 62 | assert.NoError(t, err) 63 | rs := seekstream.New(source, buffer) 64 | defer rs.Close() 65 | 66 | ... 67 | } 68 | ``` 69 | -------------------------------------------------------------------------------- /seekstream/buffer.go: -------------------------------------------------------------------------------- 1 | package seekstream 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // Buffer is the underlying buffer interface for allowing the 10 | // non-seekable source to become seekable 11 | type Buffer interface { 12 | io.ReadWriteSeeker 13 | Clear() 14 | } 15 | 16 | // TempFileBuffer Buffer implementation using temp file 17 | // suitable for unknown or large size that does not fit inside memory 18 | type TempFileBuffer struct { 19 | *os.File 20 | } 21 | 22 | // Clear performs cleanup on stream close 23 | func (b *TempFileBuffer) Clear() { 24 | filename := b.File.Name() 25 | _ = b.File.Close() 26 | _ = os.Remove(filename) 27 | } 28 | 29 | // NewTempFileBuffer new temp file buffer. Using os.CreateTemp that 30 | // creates a new temporary file in the directory dir. 31 | // The filename is generated by taking pattern and adding a random string to the end. 32 | func NewTempFileBuffer(dir, pattern string) (*TempFileBuffer, error) { 33 | file, err := os.CreateTemp(dir, pattern) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return &TempFileBuffer{file}, nil 38 | } 39 | 40 | // MemoryBuffer Buffer implementation using memory buffer 41 | // suitable for known size that can be fit inside memory 42 | type MemoryBuffer struct { 43 | buf []byte 44 | i int64 // current reading index 45 | s int64 // size 46 | } 47 | 48 | // NewMemoryBuffer new memory buffer providing data size 49 | func NewMemoryBuffer(size int64) *MemoryBuffer { 50 | return &MemoryBuffer{buf: make([]byte, size)} 51 | } 52 | 53 | // Read implements the io.Reader interface. 54 | func (r *MemoryBuffer) Read(b []byte) (n int, err error) { 55 | if r.i >= r.s { 56 | return 0, io.EOF 57 | } 58 | rs := r.buf[:r.s] 59 | n = copy(b, rs[r.i:]) 60 | r.i += int64(n) 61 | return 62 | } 63 | 64 | // Write implements the io.Writer interface. 65 | func (r *MemoryBuffer) Write(p []byte) (n int, err error) { 66 | n = copy(r.buf[r.i:], p) 67 | r.s += int64(n) 68 | r.i += int64(n) 69 | return n, nil 70 | } 71 | 72 | // Seek implements the io.Seeker interface 73 | func (r *MemoryBuffer) Seek(offset int64, whence int) (int64, error) { 74 | var abs int64 75 | switch whence { 76 | case io.SeekStart: 77 | abs = offset 78 | case io.SeekCurrent: 79 | abs = r.i + offset 80 | case io.SeekEnd: 81 | abs = r.s + offset 82 | } 83 | if abs < 0 { 84 | return 0, errors.New("invalid argument") 85 | } 86 | r.i = abs 87 | return abs, nil 88 | } 89 | 90 | // Clear performs cleanup on stream close 91 | func (r *MemoryBuffer) Clear() { 92 | r.buf = nil 93 | } 94 | -------------------------------------------------------------------------------- /seekstream/seekstream.go: -------------------------------------------------------------------------------- 1 | package seekstream 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // SeekStream allows seeking on non-seekable io.ReadCloser source 8 | // by buffering read data using memory or temp file. 9 | type SeekStream struct { 10 | source io.ReadCloser 11 | buffer Buffer 12 | size int64 13 | curr int64 14 | loaded bool 15 | } 16 | 17 | // New SeekStream proving io.ReadCloser source and buffer interface 18 | func New(source io.ReadCloser, buffer Buffer) *SeekStream { 19 | return &SeekStream{ 20 | source: source, 21 | buffer: buffer, 22 | } 23 | } 24 | 25 | // Read implements the io.Reader interface. 26 | func (s *SeekStream) Read(p []byte) (n int, err error) { 27 | if s.source == nil || s.buffer == nil { 28 | return 0, io.ErrClosedPipe 29 | } 30 | if s.loaded && s.curr >= s.size { 31 | err = io.EOF 32 | return 33 | } else if s.curr < s.size { 34 | n, err = s.buffer.Read(p) 35 | s.curr += int64(n) 36 | if err != nil && err != io.EOF { 37 | return 38 | } 39 | } 40 | if s.loaded || len(p) == n { 41 | return 42 | } 43 | pn := p[n:] 44 | var nn int 45 | nn, err = s.source.Read(pn) 46 | n += nn 47 | if nn > 0 { 48 | if n, err := s.buffer.Write(pn[:nn]); err != nil { 49 | return n, err 50 | } 51 | } 52 | if err == io.EOF { 53 | s.loaded = true 54 | } 55 | s.size += int64(nn) 56 | s.curr += int64(nn) 57 | return 58 | } 59 | 60 | // Seek implements the io.Seeker interface. 61 | func (s *SeekStream) Seek(offset int64, whence int) (int64, error) { 62 | if s.source == nil || s.buffer == nil { 63 | return 0, io.ErrClosedPipe 64 | } 65 | var dest int64 66 | switch whence { 67 | case io.SeekStart: 68 | dest = offset 69 | case io.SeekCurrent: 70 | dest = s.curr + offset 71 | case io.SeekEnd: 72 | if !s.loaded { 73 | if s.curr != s.size { 74 | n, err := s.buffer.Seek(s.size, io.SeekStart) 75 | s.curr = n 76 | if err != nil { 77 | return n, err 78 | } 79 | } 80 | n, err := io.Copy(s.buffer, s.source) 81 | if err != nil { 82 | return 0, err 83 | } 84 | s.curr += n 85 | s.size += n 86 | s.loaded = true 87 | } 88 | dest = s.size + offset 89 | } 90 | if !s.loaded && dest > s.size { 91 | nn, err := io.CopyN(s.buffer, s.source, dest-s.size) 92 | s.size += nn 93 | if err == io.EOF { 94 | s.loaded = true 95 | } else if err != nil { 96 | return 0, err 97 | } 98 | } 99 | n, err := s.buffer.Seek(dest, io.SeekStart) 100 | s.curr = n 101 | return n, err 102 | } 103 | 104 | // Close implements the io.Closer interface. 105 | func (s *SeekStream) Close() (err error) { 106 | if s.buffer != nil { 107 | s.buffer.Clear() 108 | s.buffer = nil 109 | } 110 | if s.source != nil { 111 | err = s.source.Close() 112 | s.source = nil 113 | } 114 | return 115 | } 116 | 117 | // Len returns the number of bytes of the unread portion of buffer 118 | func (s *SeekStream) Len() int { 119 | if s.curr >= s.size { 120 | return 0 121 | } 122 | return int(s.size - s.curr) 123 | } 124 | 125 | // Size returns the length of the underlying buffer 126 | func (s *SeekStream) Size() int64 { 127 | return s.size 128 | } 129 | -------------------------------------------------------------------------------- /server/handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type errResp struct { 16 | Message string `json:"message,omitempty"` 17 | Code int `json:"status,omitempty"` 18 | } 19 | 20 | func handleOk(w http.ResponseWriter, r *http.Request) { 21 | w.WriteHeader(http.StatusOK) 22 | return 23 | } 24 | 25 | func isNoopRequest(r *http.Request) bool { 26 | return r.Method == http.MethodGet && (r.URL.Path == "/healthcheck" || r.URL.Path == "/favicon.ico") 27 | } 28 | 29 | func (s *Server) panicHandler(next http.Handler) http.Handler { 30 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | defer func() { 32 | if rvr := recover(); rvr != nil { 33 | err, ok := rvr.(error) 34 | if !ok { 35 | err = fmt.Errorf("%v", rvr) 36 | } 37 | s.Logger.Error("panic", zap.Error(err)) 38 | w.WriteHeader(http.StatusInternalServerError) 39 | writeJSON(w, r, errResp{ 40 | Message: err.Error(), 41 | Code: http.StatusInternalServerError, 42 | }) 43 | } 44 | }() 45 | next.ServeHTTP(w, r) 46 | }) 47 | } 48 | 49 | func noopHandler(next http.Handler) http.Handler { 50 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | if isNoopRequest(r) { 52 | handleOk(w, r) 53 | return 54 | } 55 | next.ServeHTTP(w, r) 56 | return 57 | }) 58 | } 59 | 60 | func stripQueryStringHandler(next http.Handler) http.Handler { 61 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 | if r.URL.RawQuery != "" { 63 | r.URL.RawQuery = "" 64 | http.Redirect(w, r, r.URL.String(), http.StatusTemporaryRedirect) 65 | return 66 | } 67 | next.ServeHTTP(w, r) 68 | }) 69 | } 70 | 71 | func writeJSON(w http.ResponseWriter, r *http.Request, v interface{}) { 72 | buf, _ := json.Marshal(v) 73 | w.Header().Set("Content-Type", "application/json") 74 | w.Header().Set("Content-Length", strconv.Itoa(len(buf))) 75 | if r.Method != http.MethodHead { 76 | _, _ = w.Write(buf) 77 | } 78 | return 79 | } 80 | 81 | type statusRecorder struct { 82 | http.ResponseWriter 83 | Status int 84 | } 85 | 86 | func (r *statusRecorder) WriteHeader(status int) { 87 | r.Status = status 88 | r.ResponseWriter.WriteHeader(status) 89 | } 90 | 91 | func (s *Server) accessLogHandler(next http.Handler) http.Handler { 92 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | start := time.Now() 94 | wr := &statusRecorder{ 95 | ResponseWriter: w, 96 | Status: 200, 97 | } 98 | next.ServeHTTP(wr, r) 99 | if isNoopRequest(r) { 100 | return // skip logging no-op requests 101 | } 102 | s.Logger.Info("access", 103 | zap.Int("status", wr.Status), 104 | zap.String("method", r.Method), 105 | zap.String("uri", r.URL.RequestURI()), 106 | zap.String("ip", RealIP(r)), 107 | zap.String("user-agent", r.UserAgent()), 108 | zap.Duration("took", time.Since(start)), 109 | ) 110 | }) 111 | } 112 | 113 | type serverErrorLogWriter struct { 114 | Logger *zap.Logger 115 | } 116 | 117 | func (s *serverErrorLogWriter) Write(p []byte) (int, error) { 118 | m := string(p) 119 | if strings.HasPrefix(m, "http: TLS handshake error") && strings.HasSuffix(m, ": EOF\n") { 120 | s.Logger.Debug("server", zap.String("log", m)) // https://github.com/golang/go/issues/26918 121 | } else if strings.HasPrefix(m, "http: URL query contains semicolon") { 122 | s.Logger.Debug("server", zap.String("log", m)) // https://github.com/golang/go/issues/25192 123 | } else { 124 | s.Logger.Warn("server", zap.String("log", m)) 125 | } 126 | return len(p), nil 127 | } 128 | 129 | func newServerErrorLog(logger *zap.Logger) *log.Logger { 130 | return log.New(&serverErrorLogWriter{logger}, "", 0) 131 | } 132 | -------------------------------------------------------------------------------- /server/option.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/rs/cors" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // Option Server option 12 | type Option func(s *Server) 13 | 14 | // WithAddr with server address with port option 15 | func WithAddr(addr string) Option { 16 | return func(s *Server) { 17 | s.Addr = addr 18 | } 19 | } 20 | 21 | // WithAddress with server address option 22 | func WithAddress(address string) Option { 23 | return func(s *Server) { 24 | s.Address = address 25 | } 26 | } 27 | 28 | // WithPort with port option 29 | func WithPort(port int) Option { 30 | return func(s *Server) { 31 | s.Port = port 32 | } 33 | } 34 | 35 | // WithLogger with logger option 36 | func WithLogger(logger *zap.Logger) Option { 37 | return func(s *Server) { 38 | if logger != nil { 39 | s.Logger = logger 40 | } 41 | } 42 | } 43 | 44 | // WithMiddleware with HTTP middleware option 45 | func WithMiddleware(middleware func(http.Handler) http.Handler) Option { 46 | return func(s *Server) { 47 | if middleware != nil { 48 | s.Handler = middleware(s.Handler) 49 | } 50 | } 51 | } 52 | 53 | // WithPathPrefix with path prefix option 54 | func WithPathPrefix(prefix string) Option { 55 | return func(s *Server) { 56 | s.PathPrefix = prefix 57 | } 58 | } 59 | 60 | // WithCORS with CORS option 61 | func WithCORS(enabled bool) Option { 62 | return func(s *Server) { 63 | if enabled { 64 | s.Handler = cors.Default().Handler(s.Handler) 65 | } 66 | } 67 | } 68 | 69 | // WithDebug with debug option 70 | func WithDebug(debug bool) Option { 71 | return func(s *Server) { 72 | s.Debug = debug 73 | } 74 | } 75 | 76 | // WithSentry with sentry option 77 | func WithSentry(dsn string) Option { 78 | return func(s *Server) { 79 | s.SentryDsn = dsn 80 | } 81 | } 82 | 83 | // WithStartupTimeout with server startup timeout option 84 | func WithStartupTimeout(timeout time.Duration) Option { 85 | return func(s *Server) { 86 | if timeout > 0 { 87 | s.StartupTimeout = timeout 88 | } 89 | } 90 | } 91 | 92 | // WithShutdownTimeout with server shutdown timeout option 93 | func WithShutdownTimeout(timeout time.Duration) Option { 94 | return func(s *Server) { 95 | if timeout > 0 { 96 | s.ShutdownTimeout = timeout 97 | } 98 | } 99 | } 100 | 101 | // WithStripQueryString with strip query string option 102 | func WithStripQueryString(enabled bool) Option { 103 | return func(s *Server) { 104 | if enabled { 105 | s.Handler = stripQueryStringHandler(s.Handler) 106 | } 107 | } 108 | } 109 | 110 | // WithAccessLog with server access log option 111 | func WithAccessLog(enabled bool) Option { 112 | return func(s *Server) { 113 | if enabled { 114 | s.Handler = s.accessLogHandler(s.Handler) 115 | } 116 | } 117 | } 118 | 119 | // WithMetrics with server metrics option 120 | func WithMetrics(metrics Metrics) Option { 121 | return func(s *Server) { 122 | s.Metrics = metrics 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /server/realip.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | var cidrs []*net.IPNet 11 | 12 | func init() { 13 | maxCidrBlocks := []string{ 14 | "127.0.0.1/8", // localhost 15 | "10.0.0.0/8", // Class A private network local communication (RFC 1918) 16 | "172.16.0.0/12", // Private network - local communication (RFC 1918) 17 | "192.168.0.0/16", // Class B private network local communication (RFC 1918) 18 | "169.254.0.0/16", // link local address 19 | "100.64.0.0/10", // Carrier grade NAT (RFC 6598) 20 | "::1/128", // localhost IPv6 21 | "fc00::/7", // unique local address IPv6 22 | "fe80::/10", // link local address IPv6 23 | } 24 | 25 | cidrs = make([]*net.IPNet, len(maxCidrBlocks)) 26 | for i, maxCidrBlock := range maxCidrBlocks { 27 | _, cidr, _ := net.ParseCIDR(maxCidrBlock) 28 | cidrs[i] = cidr 29 | } 30 | } 31 | 32 | // IsPrivateIP works by checking if the address is under private CIDR blocks. 33 | // List of private CIDR blocks can be seen on : 34 | // 35 | // https://en.wikipedia.org/wiki/Private_network 36 | // 37 | // https://en.wikipedia.org/wiki/Link-local_address 38 | func IsPrivateIP(address string) (bool, error) { 39 | ipAddress := net.ParseIP(address) 40 | if ipAddress == nil { 41 | return false, errors.New("address is not valid") 42 | } 43 | for i := range cidrs { 44 | if cidrs[i].Contains(ipAddress) { 45 | return true, nil 46 | } 47 | } 48 | return false, nil 49 | } 50 | 51 | // RealIP return client's real public IP address from http request headers. 52 | func RealIP(r *http.Request) string { 53 | // Fetch header value 54 | xRealIP := r.Header.Get("X-Real-Ip") 55 | xForwardedFor := r.Header.Get("X-Forwarded-For") 56 | 57 | // If both empty, return IP from remote address 58 | if xRealIP == "" && xForwardedFor == "" { 59 | var remoteIP string 60 | 61 | // If there are colon in remote address, remove the port number 62 | // otherwise, return remote address as is 63 | if strings.ContainsRune(r.RemoteAddr, ':') { 64 | remoteIP, _, _ = net.SplitHostPort(r.RemoteAddr) 65 | } else { 66 | remoteIP = r.RemoteAddr 67 | } 68 | 69 | return remoteIP 70 | } 71 | 72 | // Check list of IP in X-Forwarded-For and return the first global address 73 | for _, address := range strings.Split(xForwardedFor, ",") { 74 | address = strings.TrimSpace(address) 75 | if isPrivate, err := IsPrivateIP(address); !isPrivate && err == nil { 76 | return address 77 | } 78 | } 79 | // If nothing succeed, return X-Real-IP 80 | return xRealIP 81 | } 82 | -------------------------------------------------------------------------------- /server/realip_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsPrivateIP(t *testing.T) { 8 | if isPrivate, err := IsPrivateIP("1.1.1.1"); isPrivate || err != nil { 9 | t.Error("should not private ip") 10 | } 11 | if isPrivate, err := IsPrivateIP("10.8.0.1"); !isPrivate || err != nil { 12 | t.Error("should private ip") 13 | } 14 | if isPrivate, err := IsPrivateIP("100.112.193.54"); !isPrivate || err != nil { 15 | t.Error("should private ip") 16 | } 17 | if _, err := IsPrivateIP("asdf"); err == nil { 18 | t.Error("should error for invalid address") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /storage/filestorage/filestorage.go: -------------------------------------------------------------------------------- 1 | package filestorage 2 | 3 | import ( 4 | "context" 5 | "github.com/cshum/imagor" 6 | "github.com/cshum/imagor/imagorpath" 7 | "io" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | var dotFileRegex = regexp.MustCompile("/\\.") 17 | 18 | // FileStorage File Storage implements imagor.Storage interface 19 | type FileStorage struct { 20 | BaseDir string 21 | PathPrefix string 22 | Blacklists []*regexp.Regexp 23 | MkdirPermission os.FileMode 24 | WritePermission os.FileMode 25 | SaveErrIfExists bool 26 | SafeChars string 27 | Expiration time.Duration 28 | 29 | safeChars imagorpath.SafeChars 30 | } 31 | 32 | // New creates FileStorage 33 | func New(baseDir string, options ...Option) *FileStorage { 34 | s := &FileStorage{ 35 | BaseDir: baseDir, 36 | PathPrefix: "/", 37 | Blacklists: []*regexp.Regexp{dotFileRegex}, 38 | MkdirPermission: 0755, 39 | WritePermission: 0666, 40 | } 41 | for _, option := range options { 42 | option(s) 43 | } 44 | s.safeChars = imagorpath.NewSafeChars(s.SafeChars) 45 | return s 46 | } 47 | 48 | // Path transforms and validates image key for storage path 49 | func (s *FileStorage) Path(image string) (string, bool) { 50 | image = "/" + imagorpath.Normalize(image, s.safeChars) 51 | for _, blacklist := range s.Blacklists { 52 | if blacklist.MatchString(image) { 53 | return "", false 54 | } 55 | } 56 | if !strings.HasPrefix(image, s.PathPrefix) { 57 | return "", false 58 | } 59 | return filepath.Join(s.BaseDir, strings.TrimPrefix(image, s.PathPrefix)), true 60 | } 61 | 62 | // Get implements imagor.Storage interface 63 | func (s *FileStorage) Get(_ *http.Request, image string) (*imagor.Blob, error) { 64 | image, ok := s.Path(image) 65 | if !ok { 66 | return nil, imagor.ErrInvalid 67 | } 68 | return imagor.NewBlobFromFile(image, func(stat os.FileInfo) error { 69 | if s.Expiration > 0 && time.Now().Sub(stat.ModTime()) > s.Expiration { 70 | return imagor.ErrExpired 71 | } 72 | return nil 73 | }), nil 74 | } 75 | 76 | // Put implements imagor.Storage interface 77 | func (s *FileStorage) Put(_ context.Context, image string, blob *imagor.Blob) (err error) { 78 | image, ok := s.Path(image) 79 | if !ok { 80 | return imagor.ErrInvalid 81 | } 82 | if err = os.MkdirAll(filepath.Dir(image), s.MkdirPermission); err != nil { 83 | return 84 | } 85 | reader, _, err := blob.NewReader() 86 | if err != nil { 87 | return err 88 | } 89 | defer func() { 90 | _ = reader.Close() 91 | }() 92 | flag := os.O_RDWR | os.O_CREATE | os.O_TRUNC 93 | if s.SaveErrIfExists { 94 | flag = os.O_RDWR | os.O_CREATE | os.O_EXCL 95 | } 96 | w, err := os.OpenFile(image, flag, s.WritePermission) 97 | if err != nil { 98 | return 99 | } 100 | defer func() { 101 | _ = w.Close() 102 | if err != nil { 103 | _ = os.Remove(w.Name()) 104 | } 105 | }() 106 | if _, err = io.Copy(w, reader); err != nil { 107 | return 108 | } 109 | if err = w.Sync(); err != nil { 110 | return 111 | } 112 | return 113 | } 114 | 115 | // Delete implements imagor.Storage interface 116 | func (s *FileStorage) Delete(_ context.Context, image string) error { 117 | image, ok := s.Path(image) 118 | if !ok { 119 | return imagor.ErrInvalid 120 | } 121 | return os.Remove(image) 122 | } 123 | 124 | // Stat implements imagor.Storage interface 125 | func (s *FileStorage) Stat(_ context.Context, image string) (stat *imagor.Stat, err error) { 126 | image, ok := s.Path(image) 127 | if !ok { 128 | return nil, imagor.ErrInvalid 129 | } 130 | osStat, err := os.Stat(image) 131 | if err != nil { 132 | if os.IsNotExist(err) { 133 | return nil, imagor.ErrNotFound 134 | } 135 | return nil, err 136 | } 137 | size := osStat.Size() 138 | modTime := osStat.ModTime() 139 | return &imagor.Stat{ 140 | Size: size, 141 | ModifiedTime: modTime, 142 | }, nil 143 | } 144 | -------------------------------------------------------------------------------- /storage/filestorage/option.go: -------------------------------------------------------------------------------- 1 | package filestorage 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // Option FileStorage option 12 | type Option func(h *FileStorage) 13 | 14 | // WithPathPrefix with path prefix option 15 | func WithPathPrefix(prefix string) Option { 16 | return func(s *FileStorage) { 17 | if prefix != "" { 18 | prefix = "/" + strings.Trim(prefix, "/") 19 | if prefix != "/" { 20 | prefix += "/" 21 | } 22 | s.PathPrefix = prefix 23 | } 24 | } 25 | } 26 | 27 | // WithBlacklist with regexp path blacklist option 28 | func WithBlacklist(blacklist *regexp.Regexp) Option { 29 | return func(s *FileStorage) { 30 | if blacklist != nil { 31 | s.Blacklists = append(s.Blacklists, blacklist) 32 | } 33 | } 34 | } 35 | 36 | // WithMkdirPermission with mkdir permission option 37 | func WithMkdirPermission(perm string) Option { 38 | return func(h *FileStorage) { 39 | if perm != "" { 40 | if fm, err := strconv.ParseUint(perm, 0, 32); err == nil { 41 | h.MkdirPermission = os.FileMode(fm) 42 | } 43 | } 44 | } 45 | } 46 | 47 | // WithWritePermission with write permission option 48 | func WithWritePermission(perm string) Option { 49 | return func(h *FileStorage) { 50 | if perm != "" { 51 | if fm, err := strconv.ParseUint(perm, 0, 32); err == nil { 52 | h.WritePermission = os.FileMode(fm) 53 | } 54 | } 55 | } 56 | } 57 | 58 | // WithSaveErrIfExists with save error if exists option 59 | func WithSaveErrIfExists(saveErrIfExists bool) Option { 60 | return func(h *FileStorage) { 61 | h.SaveErrIfExists = saveErrIfExists 62 | } 63 | } 64 | 65 | // WithSafeChars with safe chars option 66 | func WithSafeChars(chars string) Option { 67 | return func(h *FileStorage) { 68 | if chars != "" { 69 | h.SafeChars = chars 70 | } 71 | } 72 | } 73 | 74 | // WithExpiration with modified time expiration option 75 | func WithExpiration(exp time.Duration) Option { 76 | return func(h *FileStorage) { 77 | if exp > 0 { 78 | h.Expiration = exp 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /storage/gcloudstorage/gcloudstorage.go: -------------------------------------------------------------------------------- 1 | package gcloudstorage 2 | 3 | import ( 4 | "cloud.google.com/go/storage" 5 | "context" 6 | "errors" 7 | "github.com/cshum/imagor" 8 | "github.com/cshum/imagor/imagorpath" 9 | "io" 10 | "net/http" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | // GCloudStorage Google Cloud Storage implements imagor.Storage interface 17 | type GCloudStorage struct { 18 | BaseDir string 19 | PathPrefix string 20 | ACL string 21 | SafeChars string 22 | Expiration time.Duration 23 | client *storage.Client 24 | Bucket string 25 | 26 | safeChars imagorpath.SafeChars 27 | } 28 | 29 | // New creates GCloudStorage 30 | func New(client *storage.Client, bucket string, options ...Option) *GCloudStorage { 31 | s := &GCloudStorage{client: client, Bucket: bucket} 32 | for _, option := range options { 33 | option(s) 34 | } 35 | s.safeChars = imagorpath.NewSafeChars(s.SafeChars) 36 | return s 37 | } 38 | 39 | // Get implements imagor.Storage interface 40 | func (s *GCloudStorage) Get(r *http.Request, image string) (imageData *imagor.Blob, err error) { 41 | ctx := r.Context() 42 | image, ok := s.Path(image) 43 | if !ok { 44 | return nil, imagor.ErrInvalid 45 | } 46 | object := s.client.Bucket(s.Bucket).Object(image) 47 | attrs, err := object.Attrs(ctx) 48 | if err != nil { 49 | if errors.Is(err, storage.ErrObjectNotExist) { 50 | return nil, imagor.ErrNotFound 51 | } 52 | return nil, err 53 | } 54 | if s.Expiration > 0 { 55 | if attrs != nil && time.Now().Sub(attrs.Updated) > s.Expiration { 56 | return nil, imagor.ErrExpired 57 | } 58 | } 59 | blob := imagor.NewBlob(func() (reader io.ReadCloser, size int64, err error) { 60 | if err = ctx.Err(); err != nil { 61 | return 62 | } 63 | if attrs != nil { 64 | size = attrs.Size 65 | } 66 | reader, err = object.NewReader(context.Background()) 67 | return 68 | }) 69 | if attrs != nil { 70 | blob.SetContentType(attrs.ContentType) 71 | blob.Stat = &imagor.Stat{ 72 | Size: attrs.Size, 73 | ETag: attrs.Etag, 74 | ModifiedTime: attrs.Updated, 75 | } 76 | } 77 | return blob, err 78 | } 79 | 80 | // Put implements imagor.Storage interface 81 | func (s *GCloudStorage) Put(ctx context.Context, image string, blob *imagor.Blob) (err error) { 82 | image, ok := s.Path(image) 83 | if !ok { 84 | return imagor.ErrInvalid 85 | } 86 | reader, _, err := blob.NewReader() 87 | if err != nil { 88 | return err 89 | } 90 | objectHandle := s.client.Bucket(s.Bucket).Object(image) 91 | writer := objectHandle.NewWriter(ctx) 92 | defer func() { 93 | _ = reader.Close() 94 | _ = writer.Close() 95 | }() 96 | if s.ACL != "" { 97 | writer.PredefinedACL = s.ACL 98 | } 99 | writer.ContentType = blob.ContentType() 100 | if _, err = io.Copy(writer, reader); err != nil { 101 | return err 102 | } 103 | return 104 | } 105 | 106 | // Delete implements imagor.Storage interface 107 | func (s *GCloudStorage) Delete(ctx context.Context, image string) error { 108 | image, ok := s.Path(image) 109 | if !ok { 110 | return imagor.ErrInvalid 111 | } 112 | return s.client.Bucket(s.Bucket).Object(image).Delete(ctx) 113 | } 114 | 115 | // Path transforms and validates image key for storage path 116 | func (s *GCloudStorage) Path(image string) (string, bool) { 117 | image = "/" + imagorpath.Normalize(image, s.safeChars) 118 | 119 | if !strings.HasPrefix(image, s.PathPrefix) { 120 | return "", false 121 | } 122 | joinedPath := filepath.Join(s.BaseDir, strings.TrimPrefix(image, s.PathPrefix)) 123 | // Google cloud paths don't need to start with "/" 124 | return strings.Trim(joinedPath, "/"), true 125 | } 126 | 127 | // Stat implements imagor.Storage interface 128 | func (s *GCloudStorage) Stat(ctx context.Context, image string) (stat *imagor.Stat, err error) { 129 | image, ok := s.Path(image) 130 | if !ok { 131 | return nil, imagor.ErrInvalid 132 | } 133 | object := s.client.Bucket(s.Bucket).Object(image) 134 | attrs, err := object.Attrs(ctx) 135 | if err != nil { 136 | if errors.Is(err, storage.ErrObjectNotExist) { 137 | return nil, imagor.ErrNotFound 138 | } 139 | return nil, err 140 | } 141 | return &imagor.Stat{ 142 | Size: attrs.Size, 143 | ETag: attrs.Etag, 144 | ModifiedTime: attrs.Updated, 145 | }, nil 146 | } 147 | -------------------------------------------------------------------------------- /storage/gcloudstorage/option.go: -------------------------------------------------------------------------------- 1 | package gcloudstorage 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | // Option GCloudStorage option 9 | type Option func(h *GCloudStorage) 10 | 11 | // WithBaseDir with base dir option 12 | func WithBaseDir(baseDir string) Option { 13 | return func(s *GCloudStorage) { 14 | if baseDir != "" { 15 | baseDir = strings.Trim(baseDir, "/") 16 | s.BaseDir = baseDir 17 | } 18 | } 19 | } 20 | 21 | // WithPathPrefix with path prefix option 22 | func WithPathPrefix(prefix string) Option { 23 | return func(s *GCloudStorage) { 24 | if prefix != "" { 25 | prefix = "/" + strings.Trim(prefix, "/") 26 | if prefix != "/" { 27 | prefix += "/" 28 | } 29 | s.PathPrefix = prefix 30 | } 31 | } 32 | } 33 | 34 | // WithACL with ACL option 35 | // https://cloud.google.com/storage/docs/json_api/v1/objects/insert 36 | func WithACL(acl string) Option { 37 | return func(h *GCloudStorage) { 38 | h.ACL = acl 39 | } 40 | } 41 | 42 | // WithSafeChars with safe chars option 43 | func WithSafeChars(chars string) Option { 44 | return func(h *GCloudStorage) { 45 | if chars != "" { 46 | h.SafeChars = chars 47 | } 48 | } 49 | } 50 | 51 | // WithExpiration with modified time expiration option 52 | func WithExpiration(exp time.Duration) Option { 53 | return func(h *GCloudStorage) { 54 | if exp > 0 { 55 | h.Expiration = exp 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /storage/s3storage/option.go: -------------------------------------------------------------------------------- 1 | package s3storage 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/aws/aws-sdk-go/service/s3" 8 | ) 9 | 10 | // Option S3Storage option 11 | type Option func(h *S3Storage) 12 | 13 | // WithBaseDir with base dir option 14 | func WithBaseDir(baseDir string) Option { 15 | return func(s *S3Storage) { 16 | if baseDir != "" { 17 | baseDir = "/" + strings.Trim(baseDir, "/") 18 | if baseDir != "/" { 19 | baseDir += "/" 20 | } 21 | s.BaseDir = baseDir 22 | } 23 | } 24 | } 25 | 26 | // WithPathPrefix with path prefix option 27 | func WithPathPrefix(prefix string) Option { 28 | return func(s *S3Storage) { 29 | if prefix != "" { 30 | prefix = "/" + strings.Trim(prefix, "/") 31 | if prefix != "/" { 32 | prefix += "/" 33 | } 34 | s.PathPrefix = prefix 35 | } 36 | } 37 | } 38 | 39 | var aclValuesMap = (func() map[string]bool { 40 | m := map[string]bool{} 41 | for _, acl := range s3.ObjectCannedACL_Values() { 42 | m[acl] = true 43 | } 44 | return m 45 | })() 46 | 47 | // WithACL with ACL option 48 | // https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl 49 | func WithACL(acl string) Option { 50 | return func(h *S3Storage) { 51 | if aclValuesMap[acl] { 52 | h.ACL = acl 53 | } 54 | } 55 | } 56 | 57 | // WithSafeChars with safe chars option 58 | func WithSafeChars(chars string) Option { 59 | return func(h *S3Storage) { 60 | if chars != "" { 61 | h.SafeChars = chars 62 | } 63 | } 64 | } 65 | 66 | // WithExpiration with modified time expiration option 67 | func WithExpiration(exp time.Duration) Option { 68 | return func(h *S3Storage) { 69 | if exp > 0 { 70 | h.Expiration = exp 71 | } 72 | } 73 | } 74 | 75 | // WithFileStorageClass with storage storage class option 76 | func WithStorageClass(storageClass string) Option { 77 | return func(h *S3Storage) { 78 | allowedStorageClasses := [6]string{"REDUCED_REDUNDANCY", "STANDARD_IA", "ONEZONE_IA", 79 | "INTELLIGENT_TIERING", "GLACIER", "DEEP_ARCHIVE"} 80 | h.StorageClass = "STANDARD" 81 | for _, allowedStorageClass := range allowedStorageClasses { 82 | if storageClass == allowedStorageClass { 83 | h.StorageClass = storageClass 84 | break 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /testdata/2bands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/2bands.png -------------------------------------------------------------------------------- /testdata/Canon_40D.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/Canon_40D.jpg -------------------------------------------------------------------------------- /testdata/bmp_24.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/bmp_24.bmp -------------------------------------------------------------------------------- /testdata/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/demo1.jpg -------------------------------------------------------------------------------- /testdata/demo2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/demo2.jpg -------------------------------------------------------------------------------- /testdata/demo3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/demo3.gif -------------------------------------------------------------------------------- /testdata/demo3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/demo3.webp -------------------------------------------------------------------------------- /testdata/demo4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/demo4.jpg -------------------------------------------------------------------------------- /testdata/demo5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/demo5.gif -------------------------------------------------------------------------------- /testdata/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/find_trim.png -------------------------------------------------------------------------------- /testdata/find_trim_alpha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/find_trim_alpha.png -------------------------------------------------------------------------------- /testdata/golden/0.006120x0.008993%3A1.0x1.0/stretch/100x200/filters%3Abrightness%28-20%29%3Acontrast%2850%29%3Argb%2810%2C-50%2C30%29%3Afill%28black%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/0.006120x0.008993%3A1.0x1.0/stretch/100x200/filters%3Abrightness%28-20%29%3Acontrast%2850%29%3Argb%2810%2C-50%2C30%29%3Afill%28black%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/0.1x0.2%3A0.89x0.72/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/0.1x0.2%3A0.89x0.72/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/0x0/40x50/filters%3Afill%28white%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/0x0/40x50/filters%3Afill%28white%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/0x100%3A9999x9999/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/0x100%3A9999x9999/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/0x50/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/0x50/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg -------------------------------------------------------------------------------- /testdata/golden/100x100/10x5/top/filters%3Afill%28white%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x100/10x5/top/filters%3Afill%28white%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x100/10x5/top/filters%3Afill%28yellow%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x100/10x5/top/filters%3Afill%28yellow%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x100/bmp_24.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x100/bmp_24.bmp -------------------------------------------------------------------------------- /testdata/golden/100x100/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x100/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x100/filters%3Aquality%2870%29%3Aformat%28jpeg%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x100/filters%3Aquality%2870%29%3Aformat%28jpeg%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x100/lena_gray.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x100/lena_gray.bmp -------------------------------------------------------------------------------- /testdata/golden/100x100/smart/filters%3Aautojpg%28%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x100/smart/filters%3Aautojpg%28%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x200/left/bottom/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/left/bottom/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x200/left/bottom/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/left/bottom/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x200/left/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/left/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x200/left/filters%3Aorient%2890%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/left/filters%3Aorient%2890%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x200/left/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/left/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x200/right/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/right/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x200/right/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/right/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x200/right/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/right/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x200/right/top/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x200/right/top/gopher.png -------------------------------------------------------------------------------- /testdata/golden/100x30/filters%3Afocal%280.1x0%3A0.89x0.72%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x30/filters%3Afocal%280.1x0%3A0.89x0.72%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x30/filters%3Afocal%280.89x0.72%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x30/filters%3Afocal%280.89x0.72%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/100x300/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/100x300/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/10x20%3A3000x5000/stretch/100x200/filters%3Abrightness%28-20%29%3Acontrast%2850%29%3Argb%2810%2C-50%2C30%29%3Afill%28black%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/10x20%3A3000x5000/stretch/100x200/filters%3Abrightness%28-20%29%3Acontrast%2850%29%3Argb%2810%2C-50%2C30%29%3Afill%28black%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/200x-210/top/filters%3Ablur%281%2C2%29%3Asharpen%281%2C2%29%3Abackground_color%28ff0%29%3Aformat%28jpeg%29%3Aquality%2870%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x-210/top/filters%3Ablur%281%2C2%29%3Asharpen%281%2C2%29%3Abackground_color%28ff0%29%3Aformat%28jpeg%29%3Aquality%2870%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/200x-210/top/filters%3Ablur%285%29%3Asharpen%285%29%3Abackground_color%28ffff00%29%3Aformat%28jpeg%29%3Aquality%2870%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x-210/top/filters%3Ablur%285%29%3Asharpen%285%29%3Abackground_color%28ffff00%29%3Aformat%28jpeg%29%3Aquality%2870%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/200x0/20x20%3A100x20/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-10%2C-10%2C0%2C50%2C50%29%3Awatermark%28dancing-banana.gif%2C-30%2C10%2C0%2C50%2C50%29/nyan-cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x0/20x20%3A100x20/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-10%2C-10%2C0%2C50%2C50%29%3Awatermark%28dancing-banana.gif%2C-30%2C10%2C0%2C50%2C50%29/nyan-cat.gif -------------------------------------------------------------------------------- /testdata/golden/200x100/bottom/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/bottom/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/200x100/bottom/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/bottom/gopher.png -------------------------------------------------------------------------------- /testdata/golden/200x100/left/bottom/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/left/bottom/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/200x100/left/bottom/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/left/bottom/gopher.png -------------------------------------------------------------------------------- /testdata/golden/200x100/right/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/right/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/200x100/right/top/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/right/top/gopher.png -------------------------------------------------------------------------------- /testdata/golden/200x100/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/200x100/top/filters%3Aquality%2870%29%3Aformat%28tiff%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x100/top/filters%3Aquality%2870%29%3Aformat%28tiff%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/200x200/filters%3Aformat%28png%29%3Apalette%28%29%3Abitdepth%284%29%3Acompression%288%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/200x200/filters%3Aformat%28png%29%3Apalette%28%29%3Abitdepth%284%29%3Acompression%288%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%280.35x0.25%3A0.6x0.3%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%280.35x0.25%3A0.6x0.3%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%29%3Afocal%281000x814%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%29%3Afocal%281000x814%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%28589x401%3A1000x814%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%289999x9999%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/300x100/filters%3Afill%28white%29%3Aformat%28jpeg%29%3Afocal%289999x9999%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/300x300/filters%3Aformat%28jpeg%29%3Afocal%28150%3A150%29/gopher-exif-orientation-cw90.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/300x300/filters%3Aformat%28jpeg%29%3Afocal%28150%3A150%29/gopher-exif-orientation-cw90.png -------------------------------------------------------------------------------- /testdata/golden/30x20%3A100x150/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/30x20%3A100x150/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/50x0/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/50x0/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg -------------------------------------------------------------------------------- /testdata/golden/50x50%3A0x0/filters%3Atrim%2850%2Cbottom-right%29/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/50x50%3A0x0/filters%3Atrim%2850%2Cbottom-right%29/find_trim.png -------------------------------------------------------------------------------- /testdata/golden/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/disable-filters/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/disable-filters/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/disable-filters/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/disable-filters/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Abackground_color%28%29%3Around_corner%28%29%3Apadding%28%29%3Arotate%28%29%3Aproportion%28%29%3Aproportion%289999%29%3Aproportion%280.0000000001%29%3Aproportion%28-10%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Abackground_color%28%29%3Around_corner%28%29%3Apadding%28%29%3Arotate%28%29%3Aproportion%28%29%3Aproportion%289999%29%3Aproportion%280.0000000001%29%3Aproportion%28-10%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Abackground_color%28yellow%29/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Abackground_color%28yellow%29/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden/filters%3Afill%28cyan%29%3Around_corner%2860%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Afill%28cyan%29%3Around_corner%2860%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Aformat%28gif%29%3Aquality%2870%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Aformat%28gif%29%3Aquality%2870%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Aformat%28tiff%29%3Aquality%2870%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Aformat%28tiff%29%3Aquality%2870%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Aformat%28webp%29%3Aquality%2870%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Aformat%28webp%29%3Aquality%2870%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Amax_bytes%286000%29%3Aformat%28jpg%29%3Afill%28white%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Amax_bytes%286000%29%3Aformat%28jpg%29%3Afill%28white%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Amax_bytes%2860000%29%3Aformat%28jpg%29%3Afill%28white%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Amax_bytes%2860000%29%3Aformat%28jpg%29%3Afill%28white%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Amax_frames%283%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Amax_frames%283%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Apage%285%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Apage%285%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Apage%28999%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Apage%28999%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Aproportion%28%29%3Aproportion%289999%29%3Aproportion%280.0000000001%29%3Aproportion%28-10%29%3Asharpen%28-1%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Aproportion%28%29%3Aproportion%289999%29%3Aproportion%280.0000000001%29%3Aproportion%28-10%29%3Asharpen%28-1%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Aproportion%280.1%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Aproportion%280.1%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Aproportion%2810%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Aproportion%2810%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/filters%3Aquality%2860%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Aquality%2860%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Astrip_exif%28%29/Canon_40D.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Astrip_exif%28%29/Canon_40D.jpg -------------------------------------------------------------------------------- /testdata/golden/filters%3Astrip_exif%28%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Astrip_exif%28%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/filters%3Awatermark%282bands.png%2Crepeat%2Cbottom%2C40%2C25%2C50%29/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Awatermark%282bands.png%2Crepeat%2Cbottom%2C40%2C25%2C50%29/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden/filters%3Awatermark%28demo1.jpg%2Crepeat%2Crepeat%2C40%2C25%2C50%29/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/filters%3Awatermark%28demo1.jpg%2Crepeat%2Crepeat%2C40%2C25%2C50%29/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden/fit-in/-180x180/10x10/filters%3Afill%28yellow%29%3Apadding%28white%2C10%2C20%2C30%2C40%29%3Aformat%28jpeg%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/-180x180/10x10/filters%3Afill%28yellow%29%3Apadding%28white%2C10%2C20%2C30%2C40%29%3Aformat%28jpeg%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/-200x0/filters%3Ahue%28290%29%3Asaturation%28100%29%3Afill%28FFO%29%3Aupscale%28%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/-200x0/filters%3Ahue%28290%29%3Asaturation%28100%29%3Afill%28FFO%29%3Aupscale%28%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/0x210/filters%3Afill%28yellow%29%3Around_corner%2840%2C60%2Cgreen%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/0x210/filters%3Afill%28yellow%29%3Around_corner%2840%2C60%2Cgreen%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/0x50/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/0x50/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x100/10x5/filters%3Afill%28none%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x100/10x5/filters%3Afill%28none%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x100/10x5/filters%3Afill%28white%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x100/10x5/filters%3Afill%28white%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x100/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x100/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x100/demo3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x100/demo3.webp -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x100/filters%3Afill%28auto%29%3Atrim%2850%29/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x100/filters%3Afill%28auto%29%3Atrim%2850%29/find_trim.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x100/filters%3Afill%28none%29/2bands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x100/filters%3Afill%28none%29/2bands.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x100/gopher.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x100/gopher.tiff -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x120/10x5/filters%3Afill%28none%29%3Aformat%28png%29/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x120/10x5/filters%3Afill%28none%29%3Aformat%28png%29/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x150/filters%3Arotate%2890%29%3Afill%28yellow%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x150/filters%3Arotate%2890%29%3Afill%28yellow%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/100x210/10x20%3A15x3/filters%3Arotate%2890%29%3Afill%28yellow%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/100x210/10x20%3A15x3/filters%3Arotate%2890%29%3Afill%28yellow%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/150x200/10x00%3A10x50/filters%3Afill%28cyan%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cwhite%2C0%2Cmonospace%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/150x200/10x00%3A10x50/filters%3Afill%28cyan%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cwhite%2C0%2Cmonospace%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/150x200/10x00%3A10x50/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cblack%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/150x200/10x00%3A10x50/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cblack%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C30%2C-10%2C0%2C40%2C40%29%3Awatermark%28dancing-banana.gif%2C0%2C10%2C0%2C40%2C40%29/nyan-cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C30%2C-10%2C0%2C40%2C40%29%3Awatermark%28dancing-banana.gif%2C0%2C10%2C0%2C40%2C40%29/nyan-cat.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2C-20%2C-10%2C0%2C30%2C30%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2C-20%2C-10%2C0%2C30%2C30%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2Crepeat%2Cbottom%2C0%2C30%2C30%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2Crepeat%2Cbottom%2C0%2C30%2C30%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/200x210/20x20/filters%3Arotate%2890%29%3Arotate%28270%29%3Arotate%28180%29%3Afill%28blur%29%3Agrayscale%28%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/200x210/20x20/filters%3Arotate%2890%29%3Arotate%28270%29%3Arotate%28180%29%3Afill%28blur%29%3Agrayscale%28%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-0.15%2C0.1%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-0.15%2C0.1%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15%2C-10%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15%2C-10%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15p%2C10p%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15p%2C10p%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C15%2C10%2C30%2Cblue%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C15%2C10%2C30%2Cblue%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2Cbottom%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2Cbottom%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cleft%2Ctop%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cleft%2Ctop%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cright%2Ccenter%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cright%2Ccenter%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/400x400/filters%3Afill%28auto%29/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/400x400/filters%3Afill%28auto%29/find_trim.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/400x400/filters%3Afill%28auto%2Cbottom-right%29/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/400x400/filters%3Afill%28auto%2Cbottom-right%29/find_trim.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C0.1%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-0.1%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C0.1%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-0.1%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C10p%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-10p%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C10p%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-10p%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2Cleft%2Ctop%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Cright%2Ccenter%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2C-20%2C-10%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2Cleft%2Ctop%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Cright%2Ccenter%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2C-20%2C-10%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/50x0/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/50x0/filters%3Afill%28white%29%3Aformat%28jpg%29/Canon_40D.jpg -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/demo3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/demo3.webp -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/demo3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/demo3.webp -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/gopher.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/filters%3Astrip_metadata%28%29/gopher.tiff -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/67x67/gopher.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/67x67/gopher.tiff -------------------------------------------------------------------------------- /testdata/golden/fit-in/filters%3Alabel%28imagor%2C-1%2C0%2C50%29/2bands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/filters%3Alabel%28imagor%2C-1%2C0%2C50%29/2bands.png -------------------------------------------------------------------------------- /testdata/golden/fit-in/stretch/100x100/10x10/filters%3Afill%28transparent%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/fit-in/stretch/100x100/10x10/filters%3Afill%28transparent%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/max-filter-ops/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-filter-ops/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-filter-ops/fit-in/200x150/filters%3Afill%28yellow%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-filter-ops/fit-in/200x150/filters%3Afill%28yellow%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/200x100/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/200x100/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/30x20%3A100x150/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/30x20%3A100x150/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/filters%3Amax_frames%286%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/filters%3Amax_frames%286%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/max-frames-limited/trim/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames-limited/trim/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames/200x100/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames/200x100/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames/30x20%3A100x150/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames/30x20%3A100x150/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames/filters%3Afill%28white%29%3Aformat%28jpeg%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/max-frames/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/max-frames/trim/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/max-frames/trim/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/memory/30x0/filters%3Aformat%28png%29/memory-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/memory/30x0/filters%3Aformat%28png%29/memory-test.png -------------------------------------------------------------------------------- /testdata/golden/memory/filters%3Aformat%28png%29/memory-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/memory/filters%3Aformat%28png%29/memory-test.png -------------------------------------------------------------------------------- /testdata/golden/meta/Canon_40D.jpg.json: -------------------------------------------------------------------------------- 1 | {"format":"jpeg","content_type":"image/jpeg","width":100,"height":68,"orientation":1,"pages":1,"bands":3,"exif":{"ApertureValue":"368640/65536","ColorSpace":"1","ComponentsConfiguration":"Y Cb Cr -","Compression":"6","CustomRendered":"0","DateTime":"2008:07:31 10:38:11","DateTimeDigitized":"2008:05:30 15:56:01","DateTimeOriginal":"2008:05:30 15:56:01","ExifVersion":"Exif Version 2.21","ExposureBiasValue":"0/1","ExposureMode":"1","ExposureProgram":"1","ExposureTime":"1/160","FNumber":"71/10","Flash":"9","FlashpixVersion":"FlashPix Version 1.0","FocalLength":"135/1","FocalPlaneResolutionUnit":"2","FocalPlaneXResolution":"3888000/876","FocalPlaneYResolution":"2592000/583","GPSVersionID":"2.2.0.0","ISOSpeedRatings":"100","InteroperabilityIndex":"R98","InteroperabilityVersion":"0100","Make":"Canon","MeteringMode":"5","Model":"Canon EOS 40D","Orientation":"1","PixelXDimension":"100","PixelYDimension":"68","ResolutionUnit":"2","SceneCaptureType":"0","ShutterSpeedValue":"483328/65536","Software":"GIMP 2.4.5","SubSecTimeDigitized":"00","SubSecTimeOriginal":"00","SubsecTime":"00","WhiteBalance":"0","XResolution":"72/1","YCbCrPositioning":"2","YResolution":"72/1"}} -------------------------------------------------------------------------------- /testdata/golden/meta/filters%3Astrip_exif%28%29/Canon_40D.jpg.json: -------------------------------------------------------------------------------- 1 | {"format":"jpeg","content_type":"image/jpeg","width":100,"height":68,"orientation":1,"pages":1,"bands":3,"exif":{}} -------------------------------------------------------------------------------- /testdata/golden/meta/fit-in/100x100/dancing-banana.gif.json: -------------------------------------------------------------------------------- 1 | {"format":"gif","content_type":"image/gif","width":95,"height":100,"orientation":0,"pages":8,"bands":4,"exif":{}} -------------------------------------------------------------------------------- /testdata/golden/meta/fit-in/100x100/demo1.jpg.json: -------------------------------------------------------------------------------- 1 | {"format":"jpeg","content_type":"image/jpeg","width":100,"height":100,"orientation":1,"pages":1,"bands":3,"exif":{"ColorSpace":"65535","ComponentsConfiguration":"Y Cb Cr -","ExifVersion":"Exif Version 2.1","FlashpixVersion":"FlashPix Version 1.0","Orientation":"1","PixelXDimension":"200","PixelYDimension":"200","ResolutionUnit":"2","XResolution":"299999/1000","YCbCrPositioning":"1","YResolution":"299999/1000"}} -------------------------------------------------------------------------------- /testdata/golden/meta/fit-in/100x100/filters%3Aformat%28jpg%29/dancing-banana.gif.json: -------------------------------------------------------------------------------- 1 | {"format":"jpeg","content_type":"image/jpeg","width":95,"height":100,"orientation":0,"pages":1,"bands":4,"exif":{}} -------------------------------------------------------------------------------- /testdata/golden/meta/gopher-front.heif.json: -------------------------------------------------------------------------------- 1 | {"format":"heif","content_type":"image/heif","width":202,"height":259,"orientation":0,"pages":1,"bands":4,"exif":{"ColorSpace":"65535","ComponentsConfiguration":"Y Cb Cr -","ExifVersion":"Exif Version 2.1","FlashpixVersion":"FlashPix Version 1.0","PixelXDimension":"202","PixelYDimension":"259","ResolutionUnit":"2","XResolution":"72009/1000","YCbCrPositioning":"1","YResolution":"72009/1000"}} -------------------------------------------------------------------------------- /testdata/golden/meta/gopher.jp2.json: -------------------------------------------------------------------------------- 1 | {"format":"jp2k","content_type":"image/jp2","width":147,"height":200,"orientation":0,"pages":1,"bands":4,"exif":{}} -------------------------------------------------------------------------------- /testdata/golden/meta/gopher.tiff.json: -------------------------------------------------------------------------------- 1 | {"format":"tiff","content_type":"image/tiff","width":200,"height":100,"orientation":1,"pages":1,"bands":4,"exif":{}} -------------------------------------------------------------------------------- /testdata/golden/meta/sample.pdf.json: -------------------------------------------------------------------------------- 1 | {"format":"pdf","content_type":"application/pdf","width":612,"height":792,"orientation":0,"pages":2,"bands":4,"exif":{}} -------------------------------------------------------------------------------- /testdata/golden/meta/test.svg.json: -------------------------------------------------------------------------------- 1 | {"format":"svg","content_type":"image/svg+xml","width":620,"height":472,"orientation":0,"pages":1,"bands":4,"exif":{}} -------------------------------------------------------------------------------- /testdata/golden/no-animation/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/no-animation/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/no-animation/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/no-animation/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden/stretch/100x100/10x5/filters%3Afill%28white%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/stretch/100x100/10x5/filters%3Afill%28white%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/stretch/100x100/filters%3Amodulate%28-10%2C30%2C20%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/stretch/100x100/filters%3Amodulate%28-10%2C30%2C20%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden/stretch/100x200/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/stretch/100x200/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/test.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/test.svg -------------------------------------------------------------------------------- /testdata/golden/trim%3A50/500x500/filters%3Astretch%28%29/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/trim%3A50/500x500/filters%3Astretch%28%29/find_trim.png -------------------------------------------------------------------------------- /testdata/golden/trim%3Abottom-right/500x500/filters%3Astrip_exif%28%29%3Aupscale%28%29%3Ano_upscale%28%29/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/trim%3Abottom-right/500x500/filters%3Astrip_exif%28%29%3Aupscale%28%29%3Ano_upscale%28%29/find_trim.png -------------------------------------------------------------------------------- /testdata/golden/trim%3Abottom-right/50x50%3A0x0/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/trim%3Abottom-right/50x50%3A0x0/find_trim.png -------------------------------------------------------------------------------- /testdata/golden/trim/filters%3Awatermark%28%29%3Ablur%282%29%3Asharpen%282%29%3Abrightness%28%29%3Acontrast%28%29%3Ahue%28%29%3Asaturation%28%29%3Argb%28%29%3Amodulate%28%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/trim/filters%3Awatermark%28%29%3Ablur%282%29%3Asharpen%282%29%3Abrightness%28%29%3Acontrast%28%29%3Ahue%28%29%3Asaturation%28%29%3Argb%28%29%3Amodulate%28%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden/trim/find_trim_alpha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/trim/find_trim_alpha.png -------------------------------------------------------------------------------- /testdata/golden/trim/fit-in/1000x1000/filters%3Aupscale%28%29%3Astrip_icc%28%29/find_trim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden/trim/fit-in/1000x1000/filters%3Aupscale%28%29%3Astrip_icc%28%29/find_trim.png -------------------------------------------------------------------------------- /testdata/golden_arm64/100x100/10x5/top/filters%3Afill%28yellow%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/100x100/10x5/top/filters%3Afill%28yellow%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/100x100/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/100x100/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/100x200/left/bottom/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/100x200/left/bottom/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/100x200/left/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/100x200/left/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/100x200/right/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/100x200/right/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/100x200/right/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/100x200/right/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/100x30/filters%3Afocal%280.89x0.72%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/100x30/filters%3Afocal%280.89x0.72%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/200x0/20x20%3A100x20/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-10%2C-10%2C0%2C50%2C50%29%3Awatermark%28dancing-banana.gif%2C-30%2C10%2C0%2C50%2C50%29/nyan-cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/200x0/20x20%3A100x20/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-10%2C-10%2C0%2C50%2C50%29%3Awatermark%28dancing-banana.gif%2C-30%2C10%2C0%2C50%2C50%29/nyan-cat.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/200x100/bottom/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/200x100/bottom/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/200x100/left/bottom/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/200x100/left/bottom/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/200x100/right/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/200x100/right/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/200x100/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/200x100/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/200x200/filters%3Aformat%28png%29%3Apalette%28%29%3Abitdepth%284%29%3Acompression%288%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/200x200/filters%3Aformat%28png%29%3Apalette%28%29%3Abitdepth%284%29%3Acompression%288%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden_arm64/filters%3Aformat%28webp%29%3Aquality%2870%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/filters%3Aformat%28webp%29%3Aquality%2870%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/filters%3Awatermark%282bands.png%2Crepeat%2Cbottom%2C40%2C25%2C50%29/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/filters%3Awatermark%282bands.png%2Crepeat%2Cbottom%2C40%2C25%2C50%29/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden_arm64/filters%3Awatermark%28demo1.jpg%2Crepeat%2Crepeat%2C40%2C25%2C50%29/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/filters%3Awatermark%28demo1.jpg%2Crepeat%2Crepeat%2C40%2C25%2C50%29/demo1.jpg -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/100x100/demo3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/100x100/demo3.webp -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/100x150/filters%3Arotate%2890%29%3Afill%28yellow%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/100x150/filters%3Arotate%2890%29%3Afill%28yellow%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/150x200/10x00%3A10x50/filters%3Afill%28cyan%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cwhite%2C0%2Cmonospace%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/150x200/10x00%3A10x50/filters%3Afill%28cyan%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cwhite%2C0%2Cmonospace%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/150x200/10x00%3A10x50/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cblack%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/150x200/10x00%3A10x50/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2C-30%2C25%2Cblack%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C-20%2C-10%2C0%2C30%2C30%29%3Awatermark%28nyan-cat.gif%2C0%2C10%2C0%2C40%2C30%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C30%2C-10%2C0%2C40%2C40%29%3Awatermark%28dancing-banana.gif%2C0%2C10%2C0%2C40%2C40%29/nyan-cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28dancing-banana.gif%2C30%2C-10%2C0%2C40%2C40%29%3Awatermark%28dancing-banana.gif%2C0%2C10%2C0%2C40%2C40%29/nyan-cat.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2C-20%2C-10%2C0%2C30%2C30%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2C-20%2C-10%2C0%2C30%2C30%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2Crepeat%2Cbottom%2C0%2C30%2C30%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/200x150/filters%3Afill%28yellow%29%3Awatermark%28gopher-front.png%2Crepeat%2Cbottom%2C0%2C30%2C30%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-0.15%2C0.1%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-0.15%2C0.1%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15%2C-10%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15%2C-10%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15p%2C10p%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C-15p%2C10p%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C15%2C10%2C30%2Cblue%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2C15%2C10%2C30%2Cblue%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2Cbottom%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Ccenter%2Cbottom%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cleft%2Ctop%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cleft%2Ctop%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cright%2Ccenter%2C30%2Cred%2C30%29/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/300x200/10x10/filters%3Afill%28yellow%29%3Alabel%28IMAGOR%2Cright%2Ccenter%2C30%2Cred%2C30%29/gopher-front.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C0.1%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-0.1%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C0.1%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-0.1%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C10p%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-10p%29/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/500x500/filters%3Afill%28white%29%3Awatermark%28gopher.png%2C10p%2Crepeat%2C30%2C20%2C20%29%3Awatermark%28gopher.png%2Crepeat%2Cbottom%2C30%2C30%2C30%29%3Awatermark%28gopher-front.png%2Ccenter%2C-10p%29/gopher.png -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/67x67/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/67x67/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/67x67/demo3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/67x67/demo3.webp -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/67x67/filters%3Astrip_metadata%28%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/67x67/filters%3Astrip_metadata%28%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/67x67/filters%3Astrip_metadata%28%29/demo3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/67x67/filters%3Astrip_metadata%28%29/demo3.webp -------------------------------------------------------------------------------- /testdata/golden_arm64/fit-in/67x67/gopher.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/fit-in/67x67/gopher.tiff -------------------------------------------------------------------------------- /testdata/golden_arm64/max-frames-limited/200x100/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/max-frames-limited/200x100/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/max-frames-limited/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/max-frames-limited/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/max-frames/200x100/top/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/max-frames/200x100/top/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/max-frames/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/max-frames/fit-in/200x150/filters%3Afill%28cyan%29%3Awatermark%28dancing-banana.gif%2Crepeat%2Cbottom%2C0%2C50%2C50%29/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/golden_arm64/stretch/100x200/dancing-banana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/golden_arm64/stretch/100x200/dancing-banana.gif -------------------------------------------------------------------------------- /testdata/gopher-exif-orientation-cw90.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/gopher-exif-orientation-cw90.png -------------------------------------------------------------------------------- /testdata/gopher-front.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/gopher-front.avif -------------------------------------------------------------------------------- /testdata/gopher-front.heif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/gopher-front.heif -------------------------------------------------------------------------------- /testdata/gopher-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/gopher-front.png -------------------------------------------------------------------------------- /testdata/gopher.jp2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/gopher.jp2 -------------------------------------------------------------------------------- /testdata/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/gopher.png -------------------------------------------------------------------------------- /testdata/gopher.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/gopher.tiff -------------------------------------------------------------------------------- /testdata/lena_gray.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/lena_gray.bmp -------------------------------------------------------------------------------- /testdata/nyan-cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/nyan-cat.gif -------------------------------------------------------------------------------- /testdata/sample.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cshum/imagor/6dface443d493aef15de6686582c163a7b30136c/testdata/sample.pdf --------------------------------------------------------------------------------