package shortcodes import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "image" _ "image/gif" "image/jpeg" "image/png" "io/fs" "log/slog" "os" "path" "path/filepath" "strconv" "strings" "sync" "golang.org/x/image/draw" _ "golang.org/x/image/webp" "khosra/internal/content" "khosra/internal/render" ) // widths are the derivative sizes offered to a browser. Three, spanning phone to desktop: enough for srcset // to matter and few enough that the startup pass stays quick. They become a setting when someone wants a // different set, not before. var widths = []int{480, 960, 1440} // Derive writes every missing derivative for the pictures under content/, returning how many it made. // // Ahead of the request, never during one: resampling takes long enough to be felt, and there is no page cache // to hide it (ADR-0042). Idempotent, because a derivative is named after its source's content — so a rerun // stats and skips, and an edited picture simply has a different name. Originals are only ever read. func Derive(siteFS fs.FS, cacheDir string) (int, error) { if siteFS == nil || cacheDir == "" { return 0, nil } if err := os.MkdirAll(cacheDir, 0o755); err != nil { return 0, fmt.Errorf("derivative cache %s: %w", cacheDir, err) } made := 0 err := fs.WalkDir(siteFS, "content", func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || !derivable(p) { return err } if strings.Contains(p, "/"+content.ExtrasDir+"/") { // Extras are supporting material, not published pictures: deriving sizes nothing references would // only slow startup down (content-model.md). return nil } data, err := fs.ReadFile(siteFS, p) if err != nil { slog.Error("skipping unreadable picture", "path", p, "err", err) return nil } n, err := derive(data, p, cacheDir) if err != nil { // One unreadable picture must not stop a site from starting (ADR-0029). slog.Error("skipping picture", "path", p, "err", err) return nil } made += n return nil }) if err != nil { return made, fmt.Errorf("walk for pictures: %w", err) } return made, nil } // derive writes the derivatives one picture is missing. func derive(data []byte, name, cacheDir string) (int, error) { src, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return 0, err } made := 0 for _, w := range widths { if src.Bounds().Dx() <= w { // Never upscale: a bigger file that looks worse is not a derivative worth having. continue } file := filepath.Join(cacheDir, derivedName(data, name, w)) if _, err := os.Stat(file); err == nil { continue } if err := write(file, scale(src, w), name); err != nil { return made, err } made++ } return made, nil } // scale resamples to a target width, keeping the aspect ratio. // // CatmullRom because the alternative in the standard library is nearest neighbour, which is visibly wrong on // photographs — the reason ADR-0040 accepted a dependency at all. func scale(src image.Image, width int) image.Image { b := src.Bounds() height := b.Dy() * width / b.Dx() out := image.NewRGBA(image.Rect(0, 0, width, height)) draw.CatmullRom.Scale(out, out.Bounds(), src, b, draw.Over, nil) return out } // write encodes an image beside its siblings in the cache, atomically. // // Through a temporary file and a rename, so a derivative is either absent or complete: a half-written one // would be served as a broken image and, being content-named, never regenerated. func write(file string, img image.Image, source string) error { tmp, err := os.CreateTemp(filepath.Dir(file), ".khosra-*") if err != nil { return err } defer os.Remove(tmp.Name()) if err := encode(tmp, img, source); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), file) } // encode writes PNG for sources that may carry transparency and JPEG for the rest, which is also what // derivedName spells in the extension. func encode(w *os.File, img image.Image, source string) error { if lossless(source) { return png.Encode(w, img) } return jpeg.Encode(w, img, &jpeg.Options{Quality: 82}) } // inspected remembers what each picture is. // // Measured, not assumed: without this the same bytes were read, hashed and decoded on every request, costing // ~102µs per picture — a twelve-picture gallery spent 1.2ms of its 1.24ms doing work it had already done // (BenchmarkGalleryPage in internal/web). conventions.md allows a cache in the render path once a benchmark // asks for one, and this is the smallest thing the benchmark asks for. // // Keyed by name, size and modification time, so an edited picture is inspected again rather than remembered // wrongly. A map behind a mutex because requests are concurrent and there is one entry per picture on the site. var ( inspectedMu sync.Mutex inspected = map[string]render.Picture{} ) // Picture describes one image for a fragment: where to fetch it, what a browser may choose instead, and the // intrinsic size, so a page reserves the right box before any bytes arrive. // // Src stays the original: a derivative is an optimisation, and a browser that ignores srcset still gets the // picture the author put there (ADR-0042). func picture(origin render.Origin, file string) (render.Picture, bool) { if origin.Files == nil || !showable(file) { return render.Picture{}, false } // The author's file is the picture, whatever the engine can make of it. An SVG has no decoder here and an // AVIF has none anywhere, so both are rendered as they are, without a size or a srcset — dropping them // would delete content because the engine cannot optimise it. p := render.Picture{Src: file} if !derivable(file) { return p, true } name := path.Join(origin.Dir, file) info, err := fs.Stat(origin.Files, name) if err != nil { slog.Error("picture unreadable", "path", name, "err", err) return p, true } key := fmt.Sprintf("%s\x00%d\x00%d", name, info.Size(), info.ModTime().UnixNano()) inspectedMu.Lock() remembered, known := inspected[key] inspectedMu.Unlock() if known { return remembered, true } data, err := fs.ReadFile(origin.Files, name) if err != nil { slog.Error("picture unreadable", "path", name, "err", err) return p, true } cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { slog.Warn("picture kept but not sized: cannot decode", "path", name, "err", err) return p, true } p.Width, p.Height = cfg.Width, cfg.Height var sources []string for _, w := range widths { if cfg.Width <= w { continue } sources = append(sources, content.DerivedURL(derivedName(data, name, w))+" "+strconv.Itoa(w)+"w") } if len(sources) > 0 { // The original closes the set at its own width, so a wide viewport still has the best file to pick. sources = append(sources, file+" "+strconv.Itoa(cfg.Width)+"w") p.Srcset = strings.Join(sources, ", ") } inspectedMu.Lock() inspected[key] = p inspectedMu.Unlock() return p, true } // derivedName is the cache filename for one source at one width: the source's content hash, so an edit // changes the name and nothing stale is ever served, plus the width and the encoding's extension. func derivedName(data []byte, source string, width int) string { sum := sha256.Sum256(data) ext := ".jpg" if lossless(source) { ext = ".png" } return hex.EncodeToString(sum[:8]) + "-w" + strconv.Itoa(width) + ext } // lossless reports whether a source may carry transparency, which decides the derivative's encoding. func lossless(source string) bool { switch strings.ToLower(path.Ext(source)) { case ".png", ".gif", ".webp": return true } return false } // showable reports whether a browser can display the file: what belongs in a gallery, whether or not this // engine can do anything clever with it. func showable(name string) bool { switch strings.ToLower(path.Ext(name)) { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif": return true } return false } // derivable reports whether the file can be decoded and resampled here. SVG needs no resizing and AVIF has no // decoder available, so neither gets derivatives (ADR-0040). func derivable(name string) bool { switch strings.ToLower(path.Ext(name)) { case ".jpg", ".jpeg", ".png", ".gif", ".webp": return true } return false }