aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model/preview.go
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/model/preview.go')
-rw-r--r--gui/internal/model/preview.go173
1 files changed, 173 insertions, 0 deletions
diff --git a/gui/internal/model/preview.go b/gui/internal/model/preview.go
new file mode 100644
index 0000000..6ca029e
--- /dev/null
+++ b/gui/internal/model/preview.go
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "unicode/utf8"
+
+ "krino/internal/cond"
+)
+
+// PreviewKind is what a preview holds.
+type PreviewKind int
+
+const (
+ PreviewNone PreviewKind = iota
+ PreviewImage
+ PreviewText
+)
+
+// Preview is what to show of a file beside its explanation: a picture, some
+// of its text, or nothing with the reason (his request, 2026-09-16).
+type Preview struct {
+ Kind PreviewKind
+ Image string // a file to show: the file itself, or a rendered page
+ Text string
+ Note string // what this is, or why there is nothing
+}
+
+// previewBytes is how much of a text file is read, and previewLines how
+// much of it is shown: enough to recognise a document, not to read it.
+const (
+ previewBytes = 64 << 10
+ previewLines = 200
+ // imageLimit is the size above which an image is described rather than
+ // loaded: decoding a huge photograph would stall the window.
+ imageLimit = 40 << 20
+)
+
+// MakePreview looks at one file. tmp is a directory the caller owns, where
+// a rendered PDF page is written; the caller removes it. Nothing is written
+// anywhere else, and the file itself is only read.
+func MakePreview(ctx context.Context, path, tmp string) Preview {
+ fi, err := os.Stat(path)
+ if err != nil {
+ return Preview{Note: err.Error()}
+ }
+ if fi.IsDir() {
+ return Preview{Note: "a directory"}
+ }
+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
+ switch {
+ case isImageExt(ext):
+ if fi.Size() > imageLimit {
+ return Preview{Note: fmt.Sprintf("image of %s, too large to show", size(fi.Size()))}
+ }
+ return Preview{Kind: PreviewImage, Image: path, Note: fmt.Sprintf("image, %s", size(fi.Size()))}
+ case ext == "pdf":
+ return pdfPreview(ctx, path, tmp, fi.Size())
+ }
+ text, ok := textHead(path)
+ if !ok {
+ return Preview{Note: fmt.Sprintf(".%s file, %s - no preview", ext, size(fi.Size()))}
+ }
+ return Preview{Kind: PreviewText, Text: text, Note: fmt.Sprintf("first lines of %s", size(fi.Size()))}
+}
+
+// pdfPreview renders the first page if poppler can, and falls back to the
+// document's text - the same pdftotext krino's own (content ...) tests use.
+func pdfPreview(ctx context.Context, path, tmp string, sz int64) Preview {
+ if _, err := exec.LookPath("pdftoppm"); err == nil {
+ out := filepath.Join(tmp, "page")
+ cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-f", "1", "-l", "1",
+ "-scale-to", "700", "--", path, out)
+ if err := cmd.Run(); err == nil {
+ if rendered := firstMatch(out + "*.png"); rendered != "" {
+ return Preview{Kind: PreviewImage, Image: rendered,
+ Note: fmt.Sprintf("page 1, %s", size(sz))}
+ }
+ }
+ }
+ if _, err := exec.LookPath("pdftotext"); err != nil {
+ return Preview{Note: fmt.Sprintf("PDF, %s - install poppler-utils to see it", size(sz))}
+ }
+ cmd := exec.CommandContext(ctx, "pdftotext", "-l", "2", "--", path, "-")
+ text, err := cmd.Output()
+ if err != nil {
+ return Preview{Note: fmt.Sprintf("PDF, %s - pdftotext cannot read it", size(sz))}
+ }
+ return Preview{Kind: PreviewText, Text: head(string(text)),
+ Note: fmt.Sprintf("text of the first pages, %s", size(sz))}
+}
+
+// firstMatch is the first file matching a glob, "" when there is none.
+func firstMatch(glob string) string {
+ names, err := filepath.Glob(glob)
+ if err != nil || len(names) == 0 {
+ return ""
+ }
+ return names[0]
+}
+
+// textHead reads the start of a file and reports whether it is text: no NUL
+// bytes, and valid UTF-8 as far as it was read.
+func textHead(path string) (string, bool) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", false
+ }
+ defer f.Close()
+ buf := make([]byte, previewBytes)
+ n, _ := f.Read(buf)
+ buf = buf[:n]
+ if n == 0 {
+ return "", false
+ }
+ if strings.IndexByte(string(buf), 0) >= 0 {
+ return "", false
+ }
+ // A cut multi-byte character at the end is not a reason to call a file
+ // binary, so the last few bytes are dropped before the check.
+ trimmed := buf
+ for len(trimmed) > 0 && !utf8.Valid(trimmed) && len(buf)-len(trimmed) < 4 {
+ trimmed = trimmed[:len(trimmed)-1]
+ }
+ if !utf8.Valid(trimmed) {
+ return "", false
+ }
+ return head(string(trimmed)), true
+}
+
+// head is the first previewLines lines of s.
+func head(s string) string {
+ lines := strings.SplitN(s, "\n", previewLines+1)
+ if len(lines) > previewLines {
+ lines = lines[:previewLines]
+ return strings.Join(lines, "\n") + "\n..."
+ }
+ return strings.Join(lines, "\n")
+}
+
+// isImageExt reports whether an extension is one krino's (type image) would
+// take, minus the formats GTK cannot draw without extra libraries.
+func isImageExt(ext string) bool {
+ switch ext {
+ case "svg", "ico", "raw", "cr2", "nef", "arw", "dng", "heic", "heif", "avif":
+ return false
+ }
+ for _, e := range cond.Group("image") {
+ if e == ext {
+ return true
+ }
+ }
+ return false
+}
+
+// size is a file's size in the units krino's own settings use.
+func size(n int64) string {
+ switch {
+ case n >= 1<<30:
+ return fmt.Sprintf("%.1fG", float64(n)/(1<<30))
+ case n >= 1<<20:
+ return fmt.Sprintf("%.1fM", float64(n)/(1<<20))
+ case n >= 1<<10:
+ return fmt.Sprintf("%.1fK", float64(n)/(1<<10))
+ }
+ return fmt.Sprintf("%dB", n)
+}