Pure render function on a *pdftext.Doc with no I/O: the whole text with page markers when it fits, otherwise a map (title, outline with page numbers, leading pages and how to ask for more), a page range, or grep results with page numbers. Every length is in runes. Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
154 lines
4.0 KiB
Go
154 lines
4.0 KiB
Go
package pdfdoc
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"git.wntrmute.dev/kyle/sift/internal/pdftext"
|
|
)
|
|
|
|
// maxGrepMatches bounds how many match blocks are shown; the count reported is
|
|
// always the true total.
|
|
const maxGrepMatches = 100
|
|
|
|
// maxGrepPatternBytes bounds a grep pattern; a longer one is rejected before it
|
|
// can compile.
|
|
const maxGrepPatternBytes = 200
|
|
|
|
// hit is one match: the 1-based page and the 0-based line index within it.
|
|
type hit struct{ page, line int }
|
|
|
|
// renderGrep searches every line of every page in the range (all pages when no
|
|
// range is given) and returns the matches with their page numbers.
|
|
func renderGrep(doc *pdftext.Doc, opt Options) (Rendered, error) {
|
|
pattern := opt.Grep
|
|
if len(pattern) > maxGrepPatternBytes {
|
|
return Rendered{}, fmt.Errorf("%w: pattern over %d bytes", ErrBadGrep, maxGrepPatternBytes)
|
|
}
|
|
re, err := regexp.Compile(grepPattern(pattern))
|
|
if err != nil {
|
|
return Rendered{}, fmt.Errorf("%w: %v", ErrBadGrep, err)
|
|
}
|
|
|
|
start, end := 1, len(doc.Text)
|
|
if opt.Pages != "" {
|
|
s, e, err := parsePages(opt.Pages, len(doc.Text))
|
|
if err != nil {
|
|
return Rendered{}, err
|
|
}
|
|
start, end = s, e
|
|
}
|
|
|
|
var hits []hit
|
|
linesByPage := make(map[int][]string, end-start+1)
|
|
for p := start; p <= end; p++ {
|
|
lines := splitLines(doc.Text[p-1])
|
|
linesByPage[p] = lines
|
|
for j := range lines {
|
|
if re.MatchString(lines[j]) {
|
|
hits = append(hits, hit{p, j})
|
|
}
|
|
}
|
|
}
|
|
|
|
n := len(hits)
|
|
var header string
|
|
switch {
|
|
case n == 0:
|
|
header = `grep "` + pattern + `": no matches`
|
|
case n == 1:
|
|
header = `grep "` + pattern + `": 1 match`
|
|
default:
|
|
header = fmt.Sprintf(`grep "%s": %d matches`, pattern, n)
|
|
}
|
|
if n > maxGrepMatches {
|
|
header += ", showing the first " + strconv.Itoa(maxGrepMatches)
|
|
}
|
|
|
|
maxBlocks := n
|
|
if maxBlocks > maxGrepMatches {
|
|
maxBlocks = maxGrepMatches
|
|
}
|
|
|
|
outLines := make([]string, 0, 16+len(hits))
|
|
outLines = append(outLines, header)
|
|
running := utf8.RuneCountInString(header)
|
|
shown := 0
|
|
for i := 0; i < maxBlocks; i++ {
|
|
block := matchBlock(hits[i], linesByPage)
|
|
add := utf8.RuneCountInString(strings.Join(block, "\n"))
|
|
if i == 0 {
|
|
if running+add > opt.OutCap {
|
|
break
|
|
}
|
|
} else if running+1+add > opt.OutCap {
|
|
break
|
|
}
|
|
outLines = append(outLines, block...)
|
|
running += add
|
|
if i > 0 {
|
|
running++
|
|
}
|
|
shown++
|
|
}
|
|
content := strings.Join(outLines, "\n")
|
|
if utf8.RuneCountInString(content) > opt.OutCap {
|
|
content = truncateRunes(content, opt.OutCap)
|
|
}
|
|
|
|
note := ""
|
|
if shown < maxBlocks {
|
|
note = fmt.Sprintf("showing %d of %d matches", shown, n)
|
|
}
|
|
return Rendered{Content: content, GrepHits: n, Note: note}, nil
|
|
}
|
|
|
|
// grepPattern applies smart case: it lowercases-sensitively prefixes "(?i)"
|
|
// unless the pattern already contains an upper-case letter, which asks for a
|
|
// case-sensitive search.
|
|
func grepPattern(pattern string) string {
|
|
if hasUpper(pattern) {
|
|
return pattern
|
|
}
|
|
return "(?i)" + pattern
|
|
}
|
|
|
|
// hasUpper reports whether s contains an upper-case ASCII letter.
|
|
func hasUpper(s string) bool {
|
|
for _, r := range s {
|
|
if r >= 'A' && r <= 'Z' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchBlock renders one hit as its previous line, the matching line, the next
|
|
// line and a separator. Context lines that do not exist (the match is on the
|
|
// first or last line) are omitted.
|
|
func matchBlock(h hit, linesByPage map[int][]string) []string {
|
|
lines := linesByPage[h.page]
|
|
var b []string
|
|
if h.line > 0 {
|
|
b = append(b, fmt.Sprintf("p. %d- %s", h.page, strings.TrimSpace(lines[h.line-1])))
|
|
}
|
|
b = append(b, fmt.Sprintf("p. %d: %s", h.page, strings.TrimSpace(lines[h.line])))
|
|
if h.line+1 < len(lines) {
|
|
b = append(b, fmt.Sprintf("p. %d- %s", h.page, strings.TrimSpace(lines[h.line+1])))
|
|
}
|
|
return append(b, "--")
|
|
}
|
|
|
|
// splitLines splits a page into its lines, dropping the single empty line a
|
|
// trailing newline would otherwise introduce.
|
|
func splitLines(s string) []string {
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) > 1 && lines[len(lines)-1] == "" && strings.HasSuffix(s, "\n") {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines
|
|
}
|