// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "context" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "unicode/utf8" "git.labunix.xyz/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. 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 // Dir is the directory a rendered page was written to, for the caller // to remove once it is done with the image; "" when nothing was // rendered and the file itself is being shown. Dir string } // 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. px is how tall the // preview will be shown, so a page is rendered to suit it rather than to a // fixed size - 0 takes the default. Nothing is written anywhere else, and // the file itself is only read. func MakePreview(ctx context.Context, path, tmp string, px int) 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(), px) } 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. // // Each render goes in a directory of its own. pdftoppm names its output // after the page number, so a shared directory would hold several files // called page-1.png and the wrong one could be picked up - a preview could // show the page of a PDF looked at earlier. func pdfPreview(ctx context.Context, path, tmp string, sz int64, px int) Preview { if _, err := exec.LookPath("pdftoppm"); err == nil { dir, err := os.MkdirTemp(tmp, "page-") if err != nil { return Preview{Note: err.Error()} } out := filepath.Join(dir, "page") cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-f", "1", "-l", "1", "-scale-to", strconv.Itoa(renderPixels(px)), "--", path, out) if err := cmd.Run(); err == nil { if rendered := firstMatch(out + "*.png"); rendered != "" { return Preview{Kind: PreviewImage, Image: rendered, Dir: dir, Note: fmt.Sprintf("page 1, %s", size(sz))} } } os.RemoveAll(dir) } 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))} } // renderPixels is how large a page is rendered for a preview px tall: twice // the size it is shown at, so it stays sharp when the pane is dragged a // little wider, within limits that keep the render quick. func renderPixels(px int) int { n := px * 2 switch { case n < 700: return 700 case n > 2400: return 2400 } return n } // 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 { return SizeText(n) } // SizeText is a file's size in the units krino's settings are written in. func SizeText(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) }