pdfdoc: page markers, the map for long documents, page ranges, grep
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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user