Files
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

161 lines
5.8 KiB
Go

// Package thumbnail renders small preview images for stored documents, used by
// the document list's grid/thumbnail view. Like internal/ocr it shells out to
// the same poppler-utils (pdftoppm) and ImageMagick (convert) sidecars instead
// of pulling in a native image/pdf dependency.
//
// Thumbnails are derived, regenerable artefacts — never WORM. They are written
// to config.StorageConfig.ThumbnailPath() and generated lazily on first request
// (see internal/api document thumbnail handler), so existing documents get a
// thumbnail the first time they are viewed without any backfill migration.
package thumbnail
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// DefaultSize is the longest-edge pixel size of a generated thumbnail. 400 px
// is enough for a crisp grid tile on HiDPI without wasting disk/CPU.
const DefaultSize = 400
// Generator renders thumbnails via pdftoppm (PDFs) and convert (images).
type Generator struct {
PdftoppmPath string
ConvertPath string
// Timeout bounds each rendering subprocess. Default 30s.
Timeout time.Duration
// Logger is optional (nil-safe); receives best-effort diagnostics.
Logger *slog.Logger
}
// New builds a Generator with sane binary defaults.
func New(pdftoppmPath, convertPath string, timeout time.Duration) *Generator {
return &Generator{PdftoppmPath: pdftoppmPath, ConvertPath: convertPath, Timeout: timeout}
}
func (g *Generator) pdftoppmPath() string {
if strings.TrimSpace(g.PdftoppmPath) == "" {
return "pdftoppm"
}
return g.PdftoppmPath
}
func (g *Generator) convertPath() string {
if strings.TrimSpace(g.ConvertPath) == "" {
return "convert"
}
return g.ConvertPath
}
func (g *Generator) timeout() time.Duration {
if g.Timeout <= 0 {
return 30 * time.Second
}
return g.Timeout
}
func (g *Generator) log(level slog.Level, msg string, args ...any) {
if g.Logger == nil {
return
}
g.Logger.Log(context.Background(), level, msg, args...)
}
// CanRender reports whether a thumbnail can be produced for the given MIME type
// with the current binaries. Office/e-mail formats have no cheap first-page
// raster here, so the caller falls back to a generic icon in the UI.
func CanRender(mimeType string) bool {
mt := strings.ToLower(strings.TrimSpace(mimeType))
return mt == "application/pdf" || strings.HasPrefix(mt, "image/")
}
// Generate renders a PNG thumbnail of srcPath (a PDF or image, per mimeType) to
// destPath, creating parent directories as needed. Returns an error for
// unsupported types or on any subprocess failure; the caller treats a failure
// as "no thumbnail" (HTTP 404 -> generic icon), never as fatal.
func (g *Generator) Generate(ctx context.Context, srcPath, mimeType, destPath string) error {
if !CanRender(mimeType) {
return fmt.Errorf("thumbnail: unsupported mime type %q", mimeType)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
return fmt.Errorf("thumbnail: create dir: %w", err)
}
cctx, cancel := context.WithTimeout(ctx, g.timeout())
defer cancel()
mt := strings.ToLower(strings.TrimSpace(mimeType))
if mt == "application/pdf" {
return g.renderPDF(cctx, srcPath, destPath)
}
return g.renderImage(cctx, srcPath, destPath)
}
// renderPDF rasterizes the first page of a PDF to a scaled PNG via pdftoppm.
// -singlefile writes exactly <prefix>.png (no page-number suffix).
func (g *Generator) renderPDF(ctx context.Context, srcPath, destPath string) error {
bin := g.pdftoppmPath()
if _, err := exec.LookPath(bin); err != nil {
return fmt.Errorf("thumbnail: pdftoppm not found: %w", err)
}
prefix := strings.TrimSuffix(destPath, ".png")
cmd := exec.CommandContext(ctx, bin,
"-png", "-f", "1", "-l", "1",
"-scale-to", fmt.Sprintf("%d", DefaultSize),
"-singlefile", srcPath, prefix)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("thumbnail: pdftoppm timed out")
}
return fmt.Errorf("thumbnail: pdftoppm failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
if _, err := os.Stat(destPath); err != nil {
return fmt.Errorf("thumbnail: pdftoppm produced no output")
}
return nil
}
// renderImage resizes an image to a bounding box via ImageMagick convert. The
// [0] frame selector picks the first page/layer of multi-frame TIFF/GIF.
//
// -auto-orient is applied *before* -thumbnail: phone photos carry an EXIF
// Orientation tag (6 = rotate 90° CW, 8 = 90° CCW, 3 = 180°) that browsers
// honour when displaying the original (image-orientation: from-image), but
// which -thumbnail alone ignores — the thumbnail then appeared sideways next
// to an upright original. -auto-orient physically rotates the pixels and
// clears the tag, so the resulting PNG is upright regardless of how the
// consumer treats EXIF (PNG has no Orientation tag at all). Same class of bug
// as the OCR overlay coordinate fix in internal/ocr/exif.go, but solved by
// ImageMagick itself since the derived file needs no tag round-trip.
func (g *Generator) renderImage(ctx context.Context, srcPath, destPath string) error {
bin := g.convertPath()
if _, err := exec.LookPath(bin); err != nil {
return fmt.Errorf("thumbnail: convert not found: %w", err)
}
box := fmt.Sprintf("%dx%d", DefaultSize, DefaultSize)
cmd := exec.CommandContext(ctx, bin,
srcPath+"[0]", "-auto-orient", "-thumbnail", box, "-background", "white", "-alpha", "remove", destPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("thumbnail: convert timed out")
}
return fmt.Errorf("thumbnail: convert failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
if _, err := os.Stat(destPath); err != nil {
return fmt.Errorf("thumbnail: convert produced no output")
}
g.log(slog.LevelDebug, "thumbnail rendered", "src", filepath.Base(srcPath))
return nil
}