Files
kyleandornith-1.5-35b-a3b 41a68feb06 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>
2026-09-25 23:52:11 -07:00

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
}