aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model/preview.go
blob: 4ec0569a1734e8fb33900cbad9662eb817fb5eab (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
// SPDX-License-Identifier: GPL-3.0-or-later

package model

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"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
	// 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 - which is what
// happened: a preview showed the page of a PDF looked at earlier (his
// report, 2026-09-17).
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 {
	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)
}