From 0a0c7f299430a24d4409035360edae9ca77d73f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?mac=E8=8B=8F?= Date: Tue, 1 Sep 2026 20:33:43 +0800 Subject: [PATCH] feat(local): add PDF thumbnails on macOS - add an opt-in Local driver setting for PDF thumbnails - render PDF first pages with macOS Quick Look - preserve unsupported-platform behavior and cover the renderer with tests Co-authored-by: Codex <267193182+codex@users.noreply.github.com> --- drivers/local/driver.go | 6 +-- drivers/local/meta.go | 1 + drivers/local/pdf_thumb_darwin.go | 45 ++++++++++++++++++++++ drivers/local/pdf_thumb_darwin_test.go | 53 ++++++++++++++++++++++++++ drivers/local/pdf_thumb_test.go | 40 +++++++++++++++++++ drivers/local/pdf_thumb_unsupported.go | 17 +++++++++ drivers/local/util.go | 24 +++++++++++- 7 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 drivers/local/pdf_thumb_darwin.go create mode 100644 drivers/local/pdf_thumb_darwin_test.go create mode 100644 drivers/local/pdf_thumb_test.go create mode 100644 drivers/local/pdf_thumb_unsupported.go diff --git a/drivers/local/driver.go b/drivers/local/driver.go index ba7c0850b7..696e24804c 100644 --- a/drivers/local/driver.go +++ b/drivers/local/driver.go @@ -14,7 +14,6 @@ import ( "strings" "time" - "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" @@ -153,8 +152,7 @@ func (d *Local) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([ func (d *Local) FileInfoToObj(ctx context.Context, f fs.FileInfo, reqPath string, fullPath string) model.Obj { thumb := "" if d.Thumbnail { - typeName := utils.GetFileType(f.Name()) - if typeName == conf.IMAGE || typeName == conf.VIDEO { + if d.supportsThumbnail(f.Name()) { thumb = common.GetApiUrl(ctx) + stdpath.Join("/d", reqPath, f.Name()) thumb = utils.EncodePath(thumb, true) thumb += "?type=thumb&sign=" + sign.Sign(stdpath.Join(reqPath, f.Name())) @@ -240,7 +238,7 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( var thumbPath *string err := d.thumbTokenBucket.Do(ctx, func() error { var err error - buf, thumbPath, err = d.getThumb(file) + buf, thumbPath, err = d.getThumb(ctx, file) return err }) if err != nil { diff --git a/drivers/local/meta.go b/drivers/local/meta.go index a27e9eecd8..f4536bd2f7 100644 --- a/drivers/local/meta.go +++ b/drivers/local/meta.go @@ -9,6 +9,7 @@ type Addition struct { driver.RootPath DirectorySize bool `json:"directory_size" default:"false" help:"This might impact host performance"` Thumbnail bool `json:"thumbnail" required:"true" help:"enable thumbnail"` + PDFThumbnail bool `json:"pdf_thumbnail" default:"false" required:"false" help:"Generate PDF first-page thumbnails with Quick Look on macOS"` ThumbCacheFolder string `json:"thumb_cache_folder"` ThumbConcurrency string `json:"thumb_concurrency" default:"16" required:"false" help:"Number of concurrent thumbnail generation goroutines. This controls how many thumbnails can be generated in parallel."` VideoThumbPos string `json:"video_thumb_pos" default:"20%" required:"false" help:"The position of the video thumbnail. If the value is a number (integer ot floating point), it represents the time in seconds. If the value ends with '%', it represents the percentage of the video duration."` diff --git a/drivers/local/pdf_thumb_darwin.go b/drivers/local/pdf_thumb_darwin.go new file mode 100644 index 0000000000..6542cb3991 --- /dev/null +++ b/drivers/local/pdf_thumb_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package local + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +func pdfThumbnailSupported() bool { + return true +} + +func renderPDFThumbnail(ctx context.Context, fullPath string) (*bytes.Buffer, error) { + tempDir, err := os.MkdirTemp("", "openlist-pdf-thumb-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tempDir) + + renderCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cmd := exec.CommandContext(renderCtx, "/usr/bin/qlmanage", "-t", "-s", "512", "-o", tempDir, fullPath) + if output, err := cmd.CombinedOutput(); err != nil { + if renderCtx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("render PDF thumbnail timed out: %w", renderCtx.Err()) + } + if renderCtx.Err() != nil { + return nil, fmt.Errorf("render PDF thumbnail canceled: %w", renderCtx.Err()) + } + return nil, fmt.Errorf("render PDF thumbnail: %w: %s", err, bytes.TrimSpace(output)) + } + + thumbPath := filepath.Join(tempDir, filepath.Base(fullPath)+".png") + data, err := os.ReadFile(thumbPath) + if err != nil { + return nil, fmt.Errorf("read rendered PDF thumbnail: %w", err) + } + return bytes.NewBuffer(data), nil +} diff --git a/drivers/local/pdf_thumb_darwin_test.go b/drivers/local/pdf_thumb_darwin_test.go new file mode 100644 index 0000000000..ec00ed6336 --- /dev/null +++ b/drivers/local/pdf_thumb_darwin_test.go @@ -0,0 +1,53 @@ +//go:build darwin + +package local + +import ( + "bytes" + "context" + "errors" + "image/png" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestRenderPDFThumbnailDarwin(t *testing.T) { + tempDir := t.TempDir() + textPath := filepath.Join(tempDir, "source.txt") + pdfPath := filepath.Join(tempDir, "source 文件.pdf") + if err := os.WriteFile(textPath, []byte("OpenList PDF thumbnail integration test\n"), 0o600); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("/usr/sbin/cupsfilter", textPath) + pdfData, err := cmd.Output() + if err != nil { + t.Fatalf("create fixture PDF: %v", err) + } + if err := os.WriteFile(pdfPath, pdfData, 0o600); err != nil { + t.Fatal(err) + } + + thumb, err := renderPDFThumbnail(context.Background(), pdfPath) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(thumb.Bytes(), []byte("\x89PNG\r\n\x1a\n")) { + t.Fatal("rendered thumbnail is not PNG") + } + cfg, err := png.DecodeConfig(bytes.NewReader(thumb.Bytes())) + if err != nil { + t.Fatalf("decode thumbnail: %v", err) + } + if cfg.Width <= 0 || cfg.Height <= 0 { + t.Fatalf("invalid thumbnail dimensions: %dx%d", cfg.Width, cfg.Height) + } + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := renderPDFThumbnail(canceledCtx, pdfPath); !errors.Is(err, context.Canceled) { + t.Fatalf("renderPDFThumbnail with canceled context returned %v, want context.Canceled", err) + } +} diff --git a/drivers/local/pdf_thumb_test.go b/drivers/local/pdf_thumb_test.go new file mode 100644 index 0000000000..614232fb72 --- /dev/null +++ b/drivers/local/pdf_thumb_test.go @@ -0,0 +1,40 @@ +package local + +import ( + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" +) + +func TestSupportsThumbnail(t *testing.T) { + oldImages := conf.SlicesMap[conf.ImageTypes] + oldVideos := conf.SlicesMap[conf.VideoTypes] + conf.SlicesMap[conf.ImageTypes] = []string{"jpg"} + conf.SlicesMap[conf.VideoTypes] = []string{"mp4"} + t.Cleanup(func() { + conf.SlicesMap[conf.ImageTypes] = oldImages + conf.SlicesMap[conf.VideoTypes] = oldVideos + }) + + tests := []struct { + name string + fileName string + pdfThumbnail bool + want bool + }{ + {name: "image", fileName: "cover.jpg", want: true}, + {name: "video", fileName: "movie.mp4", want: true}, + {name: "PDF disabled by default", fileName: "document.pdf", want: false}, + {name: "unrelated document", fileName: "document.txt", pdfThumbnail: true, want: false}, + {name: "PDF enabled when renderer is available", fileName: "document.PDF", pdfThumbnail: true, want: pdfThumbnailSupported()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Local{Addition: Addition{PDFThumbnail: tt.pdfThumbnail}} + if got := d.supportsThumbnail(tt.fileName); got != tt.want { + t.Fatalf("supportsThumbnail(%q) = %v, want %v", tt.fileName, got, tt.want) + } + }) + } +} diff --git a/drivers/local/pdf_thumb_unsupported.go b/drivers/local/pdf_thumb_unsupported.go new file mode 100644 index 0000000000..deb8e99f27 --- /dev/null +++ b/drivers/local/pdf_thumb_unsupported.go @@ -0,0 +1,17 @@ +//go:build !darwin + +package local + +import ( + "bytes" + "context" + "errors" +) + +func pdfThumbnailSupported() bool { + return false +} + +func renderPDFThumbnail(context.Context, string) (*bytes.Buffer, error) { + return nil, errors.New("PDF thumbnails are not supported on this platform") +} diff --git a/drivers/local/util.go b/drivers/local/util.go index f9f4f4c45f..b3ccc1fd8b 100644 --- a/drivers/local/util.go +++ b/drivers/local/util.go @@ -2,6 +2,7 @@ package local import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -127,7 +128,19 @@ func (d *Local) removeThumbCache(fullPath string) { _ = os.Remove(thumbPath) } -func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) { +func (d *Local) supportsThumbnail(name string) bool { + typeName := utils.GetFileType(name) + if typeName == conf.IMAGE || typeName == conf.VIDEO { + return true + } + return d.supportsPDFThumbnail(name) +} + +func (d *Local) supportsPDFThumbnail(name string) bool { + return d.PDFThumbnail && pdfThumbnailSupported() && strings.EqualFold(filepath.Ext(name), ".pdf") +} + +func (d *Local) getThumb(ctx context.Context, file model.Obj) (*bytes.Buffer, *string, error) { fullPath := file.GetPath() if d.ThumbCacheFolder != "" { // skip if the file is a thumbnail @@ -140,12 +153,19 @@ func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) { } } var srcBuf *bytes.Buffer - if utils.GetFileType(file.GetName()) == conf.VIDEO { + typeName := utils.GetFileType(file.GetName()) + if typeName == conf.VIDEO { videoBuf, err := d.GetSnapshot(fullPath) if err != nil { return nil, nil, err } srcBuf = videoBuf + } else if d.supportsPDFThumbnail(file.GetName()) { + pdfBuf, err := renderPDFThumbnail(ctx, fullPath) + if err != nil { + return nil, nil, err + } + srcBuf = pdfBuf } else { imgData, err := os.ReadFile(fullPath) if err != nil {