From d269c8b1b4c468755ffdf3151dbb0bab3e248554 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Thu, 2 Jul 2026 01:53:23 +0200 Subject: [PATCH] Aspect-preserved detail image with full-size download Photo detail page previously showed a square crop of the source image. Now generate an aspect-preserved medium JPEG (longest side 1600, quality 88) and display that on the detail page instead. The original file is linked from a click-through on the image and from a sidebar 'Full size' download link. Also skip regeneration of images/thumbs/medium when the destination is already at least as new as the source, so a rebuild triggered by editing one sidecar (or restarting the service) no longer redecodes and rescales every photo. --- generator.go | 87 ++++++++++++++++++++++++++++++++++++++++---- static/style.css | 37 +++++++++++++++++-- templates/photo.html | 5 ++- 3 files changed, 117 insertions(+), 12 deletions(-) diff --git a/generator.go b/generator.go index fe528b2..1148cb7 100644 --- a/generator.go +++ b/generator.go @@ -20,7 +20,10 @@ import ( xdraw "golang.org/x/image/draw" ) -const thumbSize = 600 +const ( + thumbSize = 600 + mediumMaxSize = 1600 +) // PhotoMeta is the structure of a .toml sidecar file. type PhotoMeta struct { @@ -33,6 +36,7 @@ type Photo struct { Slug string File string // relative path under public/, e.g. "images/foo.jpg" Thumb string // relative path under public/, e.g. "thumbs/foo.jpg" + Medium string // relative path under public/, e.g. "medium/foo.jpg" Caption string Date time.Time } @@ -66,7 +70,7 @@ func (g *Generator) Build() error { return err } - for _, d := range []string{"images", "thumbs", "photo", "static"} { + for _, d := range []string{"images", "thumbs", "medium", "photo", "static"} { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { return err } @@ -137,6 +141,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) { Slug: slug, File: "images/" + name, Thumb: "thumbs/" + slug + ".jpg", + Medium: "medium/" + slug + ".jpg", Caption: meta.Caption, Date: date, }) @@ -149,13 +154,43 @@ func (g *Generator) loadPhotos() ([]Photo, error) { } // processImage copies the original to public/images/ and writes a square -// thumbnail to public/thumbs/. +// thumbnail to public/thumbs/ plus an aspect-preserved medium image to +// public/medium/. Outputs are skipped when already newer than the source. func (g *Generator) processImage(p *Photo) error { - src := filepath.Join(g.contentDir, filepath.Base(p.File)) - if err := copyFile(src, filepath.Join(g.outputDir, p.File)); err != nil { - return err + src := filepath.Join(g.contentDir, filepath.Base(p.File)) + dstImg := filepath.Join(g.outputDir, p.File) + dstThumb := filepath.Join(g.outputDir, p.Thumb) + dstMedium := filepath.Join(g.outputDir, p.Medium) + + if !upToDate(src, dstImg) { + if err := copyFile(src, dstImg); err != nil { + return err + } } - return generateThumb(src, filepath.Join(g.outputDir, p.Thumb), thumbSize) + if !upToDate(src, dstThumb) { + if err := generateThumb(src, dstThumb, thumbSize); err != nil { + return err + } + } + if !upToDate(src, dstMedium) { + if err := generateMedium(src, dstMedium, mediumMaxSize); err != nil { + return err + } + } + return nil +} + +// upToDate reports whether dst exists and is at least as new as src. +func upToDate(src, dst string) bool { + si, err := os.Stat(src) + if err != nil { + return false + } + di, err := os.Stat(dst) + if err != nil { + return false + } + return !di.ModTime().Before(si.ModTime()) } // generateThumb reads src, centre-crops it to a square, scales it to size×size, @@ -196,6 +231,44 @@ func generateThumb(src, dst string, size int) error { return jpeg.Encode(out, thumb, &jpeg.Options{Quality: 85}) } +// generateMedium reads src, resizes it so its longest side is at most maxDim +// while preserving aspect ratio, and writes a JPEG to dst. +func generateMedium(src, dst string, maxDim int) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + return err + } + + b := img.Bounds() + w, h := b.Dx(), b.Dy() + nw, nh := w, h + if w > maxDim || h > maxDim { + if w >= h { + nw = maxDim + nh = h * maxDim / w + } else { + nh = maxDim + nw = w * maxDim / h + } + } + + resized := image.NewRGBA(image.Rect(0, 0, nw, nh)) + xdraw.CatmullRom.Scale(resized, resized.Bounds(), img, b, xdraw.Over, nil) + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88}) +} + // renderIndex writes public/index.html. func (g *Generator) renderIndex(photos []Photo) error { return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{ diff --git a/static/style.css b/static/style.css index e79032d..9a5f8fe 100644 --- a/static/style.css +++ b/static/style.css @@ -183,21 +183,32 @@ body { .dlayout { display: flex; + align-items: stretch; border: 1px solid var(--line); } .dimg-wrap { flex: 1; - aspect-ratio: 1; + min-width: 0; overflow: hidden; background: var(--bg1); + display: flex; + align-items: center; + justify-content: center; +} + +.dimg-wrap a { + display: block; + max-width: 100%; + max-height: 85vh; } .dimg-wrap img { - width: 100%; - height: 100%; - object-fit: cover; display: block; + max-width: 100%; + max-height: 85vh; + width: auto; + height: auto; } .dsidebar { @@ -257,6 +268,24 @@ body { letter-spacing: 0.06em; } +.dfull { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.dfull a { + font-size: 13px; + font-family: monospace; + color: var(--aqua); + text-decoration: none; + letter-spacing: 0.04em; +} + +.dfull a:hover { + color: var(--fg2); +} + /* ── Responsive ────────────────────────────────────────────── */ @media (max-width: 600px) { diff --git a/templates/photo.html b/templates/photo.html index 1e6306a..3d2cb2d 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -13,7 +13,9 @@ ← Back
- {{.Photo.Caption}} + + {{.Photo.Caption}} +
@@ -22,6 +24,7 @@

{{.Photo.Caption}}

{{.Photo.Date.Format "Jan 02, 2006"}}

+

↓ Full size