Skip to content

Commit

Permalink
Package enhancedimg
Browse files Browse the repository at this point in the history
  • Loading branch information
seantiz committed Dec 18, 2024
0 parents commit 1aaa8f2
Show file tree
Hide file tree
Showing 8 changed files with 517 additions and 0 deletions.
21 changes: 21 additions & 0 deletions LICENSE
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.
52 changes: 52 additions & 0 deletions README.md
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.
207 changes: 207 additions & 0 deletions enhancedimg/enhance.go
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
}
Loading

0 comments on commit 1aaa8f2

Please sign in to comment.