Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
161 lines
5.8 KiB
Go
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
|
|
}
|