-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 1aaa8f2
Showing
8 changed files
with
517 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
MIT License | ||
|
||
Copyright (c) 2024 Sean McGhee | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
# Enhanced Images (Go) | ||
|
||
Use this for serving optimised images in your Go + HTML/X web apps. Please remember to run this as a pre-processor before any `go build` command. | ||
|
||
### What It Actually Does | ||
|
||
__Enhanced Images Go__ parses your app's source HTML templates and replaces all optimisable `<img>` elements with a `<picture>` element containing the responsive-friendly images inside. | ||
|
||
It's still in alpha because I've yet to implement ability to apply transformation effects to target images. | ||
|
||
## Quick Start | ||
|
||
Installation options: | ||
|
||
1. Install globally: | ||
```bash | ||
go install github.com/seantiz/enhancedimg-go@latest | ||
``` | ||
|
||
Then run with `enhancedimg-go` command | ||
|
||
2. Via Makefile: Create a Makefile to run `enhanceddimg-go` programatically before your 'go build' command. | ||
|
||
## Features | ||
|
||
- Responsive web design supported but no data transforms handled yet. | ||
- All common device sizes supported - ranging from Tailwind's small ("sm") device breakpoint and up to 4K (and slightly beyond). | ||
- WEBP, AVIF not yet supported but are handled by falling back to PNG on conversion. | ||
- HEIF and TIFF source images are converted to JPEG. | ||
|
||
## Dependencies and Limitations (PRs welcome) | ||
|
||
1. I leaned on the standard Go HTML parser in this package. I'll look at any way to shave down this dependency, if possible. | ||
|
||
2. I couldn't find a clean way to encode WebP images in Go just yet, despite the fact `optimisable()` utility tries to match the `.webp` format during parsing. The solutions I find meant pulling in more dependencies. | ||
|
||
3. Right now this library (as of v0.3.0) deals purely with resizing images for responsive design but actual image enhancement still needs to be implement by reading all metadata for transformation attributes. | ||
|
||
## Long-Term Performance? | ||
|
||
Pre-processing time increases with content size, so I initially built a version of enhancedimg-go that uses `bimg` for a more performant image-processing step. | ||
|
||
I decided against going down that route because it means: | ||
|
||
1. Any developer would have to install `libvips` as a system-level dependency | ||
2. I don't know any medium or entreprise-scale web apps published with a Go + HTML/X stack as of yet, so content size (and processing time) seems like it has a modest upper bounds in most cases right now. | ||
|
||
Feel free to get in touch if you disagree or you're running a Go + HTML/X codebase where the content size demands greater performance. | ||
|
||
## Credit Where It's Due | ||
|
||
The logic behind this package was heavily lifted from the SvelteJS enhanced-img preprocessor (being a big Svelte user myself) and Tailwind. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
package enhancedimg | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"image" | ||
"math" | ||
"os" | ||
"path/filepath" | ||
"strings" | ||
|
||
"image/gif" | ||
"image/jpeg" | ||
"image/png" | ||
) | ||
|
||
var fallbacks map[string]selectedEncoder | ||
|
||
type enhancedImg struct { | ||
sourceImagePath string | ||
sources map[string][]string | ||
img struct { | ||
src string | ||
width int | ||
height int | ||
} | ||
} | ||
|
||
type selectedEncoder struct { | ||
name string | ||
encode func(img image.Image, quality int) ([]byte, error) | ||
quality int | ||
} | ||
|
||
type sizeVariant struct { | ||
width int | ||
label string | ||
} | ||
|
||
func (ei enhancedImg) aspectRatio() float64 { | ||
return float64(ei.img.height) / float64(ei.img.width) | ||
} | ||
|
||
func init() { | ||
fallbacks = map[string]selectedEncoder{ | ||
".avif": {"png", encodePNG, 0}, | ||
".gif": {"gif", encodeGIF, 0}, | ||
".heif": {"jpg", encodeJPEG, 85}, | ||
".jpeg": {"jpg", encodeJPEG, 85}, | ||
".jpg": {"jpg", encodeJPEG, 85}, | ||
".png": {"png", encodePNG, 0}, | ||
".tiff": {"jpg", encodeJPEG, 85}, | ||
".webp": {"png", encodePNG, 0}, | ||
} | ||
} | ||
|
||
func encodeJPEG(img image.Image, quality int) ([]byte, error) { | ||
var buf bytes.Buffer | ||
options := &jpeg.Options{ | ||
Quality: quality, | ||
} | ||
if err := jpeg.Encode(&buf, img, options); err != nil { | ||
return nil, fmt.Errorf("unexpected error encoding JPEG: %w", err) | ||
} | ||
return buf.Bytes(), nil | ||
} | ||
|
||
func encodePNG(img image.Image, _ int) ([]byte, error) { | ||
var buf bytes.Buffer | ||
if err := png.Encode(&buf, img); err != nil { | ||
return nil, err | ||
} | ||
return buf.Bytes(), nil | ||
} | ||
|
||
func encodeGIF(img image.Image, _ int) ([]byte, error) { | ||
var buf bytes.Buffer | ||
if err := gif.Encode(&buf, img, nil); err != nil { | ||
return nil, err | ||
} | ||
return buf.Bytes(), nil | ||
} | ||
|
||
func selectEncoder(inputFormat string) selectedEncoder { | ||
if encoder, ok := fallbacks[strings.ToLower(inputFormat)]; ok { | ||
return encoder | ||
} | ||
return selectedEncoder{"jpg", encodeJPEG, 85} | ||
} | ||
|
||
func enhanceImage(src string) (enhancedImg, error) { | ||
src = strings.TrimPrefix(src, "/") | ||
|
||
f, err := os.Open(src) | ||
if err != nil { | ||
return enhancedImg{}, fmt.Errorf("couldn't open file %s: %w", src, err) | ||
} | ||
defer f.Close() | ||
|
||
img, format, err := image.Decode(f) | ||
if err != nil { | ||
return enhancedImg{}, fmt.Errorf("couldn't decode image %s (format: %s): %w", src, format, err) | ||
} | ||
|
||
inputExt := filepath.Ext(src) | ||
encoder := selectEncoder(inputExt) | ||
|
||
formats := []selectedEncoder{ | ||
encoder, | ||
} | ||
|
||
bounds := img.Bounds() | ||
enhancedImage := enhancedImg{ | ||
sourceImagePath: src, | ||
sources: make(map[string][]string), | ||
img: struct { | ||
src string | ||
width int | ||
height int | ||
}{ | ||
src: src, | ||
width: bounds.Dx(), | ||
height: bounds.Dy(), | ||
}, | ||
} | ||
|
||
// This helper ensures we don't return larger sizes than source image | ||
sizes := calculateSizeVariants(bounds.Dx()) | ||
|
||
baseFileName := filepath.Base(src) | ||
baseFileName = strings.TrimSuffix(baseFileName, filepath.Ext(baseFileName)) | ||
|
||
for _, format := range formats { | ||
if len(formats) == 0 { | ||
return enhancedImg{}, fmt.Errorf("couldn't find any valid images") | ||
} | ||
srcsets := []string{} | ||
|
||
for _, size := range sizes { | ||
height := int(math.Round(float64(size.width) * enhancedImage.aspectRatio())) | ||
resized := resizeImage(img, size.width, height) | ||
processed, err := format.encode(resized, format.quality) | ||
if err != nil { | ||
continue | ||
} | ||
|
||
processedFileName := fmt.Sprintf("%s-%s-%d.%s", | ||
baseFileName, | ||
size.label, | ||
size.width, | ||
format.name, | ||
) | ||
|
||
outPath := filepath.Join("static", "processed", processedFileName) | ||
if err := os.WriteFile(outPath, processed, 0644); err != nil { | ||
continue | ||
} | ||
|
||
srcsets = append(srcsets, fmt.Sprintf("/static/processed/%s %dw", | ||
processedFileName, | ||
size.width, | ||
)) | ||
} | ||
|
||
if len(srcsets) > 0 { | ||
enhancedImage.sources[format.name] = srcsets | ||
} | ||
} | ||
|
||
return enhancedImage, nil | ||
} | ||
|
||
func calculateSizeVariants(originalWidth int) []sizeVariant { | ||
if originalWidth <= 0 { | ||
return []sizeVariant{} | ||
} | ||
// Sizes beyond 2xl follow SvelteJS's reasoning and https://screensiz.es/ common device sizes | ||
sizes := []sizeVariant{ | ||
{width: 640, label: "sm"}, | ||
{width: 768, label: "md"}, | ||
{width: 1024, label: "lg"}, | ||
{width: 1280, label: "xl"}, | ||
{width: 1536, label: "2xl"}, | ||
{width: 1920, label: "3xl"}, // 1080p | ||
{width: 2560, label: "4xl"}, // 2K | ||
{width: 3000, label: "5xl"}, | ||
{width: 4096, label: "6xl"}, // 4K | ||
{width: 5120, label: "7xl"}, | ||
} | ||
|
||
var result []sizeVariant | ||
for _, size := range sizes { | ||
if size.width > originalWidth { | ||
break | ||
} | ||
result = append(result, size) | ||
} | ||
|
||
if len(result) == 0 || result[len(result)-1].width != originalWidth { | ||
result = append(result, sizeVariant{ | ||
width: originalWidth, | ||
label: "original", | ||
}) | ||
} | ||
|
||
return result | ||
} |
Oops, something went wrong.