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.
This commit is contained in:
+80
-7
@@ -20,7 +20,10 @@ import (
|
|||||||
xdraw "golang.org/x/image/draw"
|
xdraw "golang.org/x/image/draw"
|
||||||
)
|
)
|
||||||
|
|
||||||
const thumbSize = 600
|
const (
|
||||||
|
thumbSize = 600
|
||||||
|
mediumMaxSize = 1600
|
||||||
|
)
|
||||||
|
|
||||||
// PhotoMeta is the structure of a .toml sidecar file.
|
// PhotoMeta is the structure of a .toml sidecar file.
|
||||||
type PhotoMeta struct {
|
type PhotoMeta struct {
|
||||||
@@ -33,6 +36,7 @@ type Photo struct {
|
|||||||
Slug string
|
Slug string
|
||||||
File string // relative path under public/, e.g. "images/foo.jpg"
|
File string // relative path under public/, e.g. "images/foo.jpg"
|
||||||
Thumb string // relative path under public/, e.g. "thumbs/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
|
Caption string
|
||||||
Date time.Time
|
Date time.Time
|
||||||
}
|
}
|
||||||
@@ -66,7 +70,7 @@ func (g *Generator) Build() error {
|
|||||||
return err
|
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 {
|
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -137,6 +141,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
|
|||||||
Slug: slug,
|
Slug: slug,
|
||||||
File: "images/" + name,
|
File: "images/" + name,
|
||||||
Thumb: "thumbs/" + slug + ".jpg",
|
Thumb: "thumbs/" + slug + ".jpg",
|
||||||
|
Medium: "medium/" + slug + ".jpg",
|
||||||
Caption: meta.Caption,
|
Caption: meta.Caption,
|
||||||
Date: date,
|
Date: date,
|
||||||
})
|
})
|
||||||
@@ -149,13 +154,43 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processImage copies the original to public/images/ and writes a square
|
// 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 {
|
func (g *Generator) processImage(p *Photo) error {
|
||||||
src := filepath.Join(g.contentDir, filepath.Base(p.File))
|
src := filepath.Join(g.contentDir, filepath.Base(p.File))
|
||||||
if err := copyFile(src, filepath.Join(g.outputDir, p.File)); err != nil {
|
dstImg := filepath.Join(g.outputDir, p.File)
|
||||||
return err
|
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,
|
// 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})
|
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.
|
// renderIndex writes public/index.html.
|
||||||
func (g *Generator) renderIndex(photos []Photo) error {
|
func (g *Generator) renderIndex(photos []Photo) error {
|
||||||
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
|
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
|
||||||
|
|||||||
+33
-4
@@ -183,21 +183,32 @@ body {
|
|||||||
|
|
||||||
.dlayout {
|
.dlayout {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dimg-wrap {
|
.dimg-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
aspect-ratio: 1;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg1);
|
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 {
|
.dimg-wrap img {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dsidebar {
|
.dsidebar {
|
||||||
@@ -257,6 +268,24 @@ body {
|
|||||||
letter-spacing: 0.06em;
|
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 ────────────────────────────────────────────── */
|
/* ── Responsive ────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
<a href="/" class="back-btn">← Back</a>
|
<a href="/" class="back-btn">← Back</a>
|
||||||
<div class="dlayout">
|
<div class="dlayout">
|
||||||
<div class="dimg-wrap">
|
<div class="dimg-wrap">
|
||||||
<img src="/{{.Photo.File}}" alt="{{.Photo.Caption}}">
|
<a href="/{{.Photo.File}}" target="_blank" rel="noopener">
|
||||||
|
<img src="/{{.Photo.Medium}}" alt="{{.Photo.Caption}}">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="dsidebar">
|
<div class="dsidebar">
|
||||||
<div class="dauthor">
|
<div class="dauthor">
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="dcaption">{{.Photo.Caption}}</p>
|
<p class="dcaption">{{.Photo.Caption}}</p>
|
||||||
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
|
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
|
||||||
|
<p class="dfull"><a href="/{{.Photo.File}}" download>↓ Full size</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user