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.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user