// 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 .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 }