Files
enkly-ai/pkg/officekit/pdf.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

207 lines
7.5 KiB
Go

// pdf.go adds best-effort PDF text extraction: read_pdf's only job is
// "give me back the words", not full-fidelity layout, so this
// deliberately stays a few hundred lines of pure stdlib (no PDF
// library, no cgo, nothing to go get in an offline deployment) rather
// than a complete PDF renderer. It walks the file's raw bytes for
// `stream ... endstream` blocks, inflates the ones declared
// FlateDecode (the overwhelming majority of PDFs produced by real
// tools — Word, LibreOffice, browsers, Pandoc, Pages — all compress
// their content streams this way), and scans the decompressed
// PostScript-like content for the handful of text-showing operators
// (Tj, TJ, ', ") that actually place characters on the page.
// Everything else in the file — fonts, images, vector graphics,
// annotations, page geometry — is ignored.
//
// What this can't do, and won't pretend to: scanned/image-only PDFs
// (no embedded text at all — that needs OCR, out of scope here),
// PDFs whose content streams use a filter other than FlateDecode
// (rare in practice), and any PDF that encodes its text with a custom
// font encoding and no usable mapping back to plain characters — those
// come out as empty or as mangled text, and ReadPdfText says so rather
// than silently returning garbage.
package officekit
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)
// pdfStreamRe finds every `stream\r?\n ... \r?\nendstream` block in
// the file, capturing the body separately from the "stream" keyword
// that precedes it — ReadPdfText looks backward from the keyword for
// the object dictionary's /Filter entry rather than actually parsing
// PDF's object/dictionary syntax.
var pdfStreamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`)
// pdfDictLookback is how far back from a `stream` keyword ReadPdfText
// looks for that stream object's dictionary (and in particular its
// /Filter entry) — generous enough for any realistic content-stream
// dictionary (/Length, /Filter, /Length1, a handful of other keys)
// without risking bleeding into the previous object.
const pdfDictLookback = 600
// ReadPdfText extracts a PDF's plain text content, best-effort. See
// the package doc above for exactly what it does and doesn't handle.
// Returns an error only when the input isn't recognizable as a PDF,
// or no extractable text was found anywhere in it — a PDF that fails
// to extract from some streams but not others still returns whatever
// text it did recover from the rest.
func ReadPdfText(data []byte) (string, error) {
head := data
if len(head) > 1024 {
head = head[:1024]
}
if !bytes.Contains(head, []byte("%PDF-")) {
return "", fmt.Errorf("officekit: not a PDF file (missing %%PDF- header)")
}
var out strings.Builder
for _, loc := range pdfStreamRe.FindAllSubmatchIndex(data, -1) {
streamStart, bodyStart, bodyEnd := loc[0], loc[2], loc[3]
dictStart := streamStart - pdfDictLookback
if dictStart < 0 {
dictStart = 0
}
dict := data[dictStart:streamStart]
if !bytes.Contains(dict, []byte("FlateDecode")) {
continue // not a stream this package knows how to decompress
}
inflated, err := inflateZlib(data[bodyStart:bodyEnd])
if err != nil {
continue // FlateDecode-tagged but not plain DEFLATE (e.g. an embedded font program); skip it
}
if text := extractShownText(inflated); text != "" {
if out.Len() > 0 {
out.WriteString("\n\n")
}
out.WriteString(text)
}
}
result := strings.TrimSpace(out.String())
if result == "" {
return "", fmt.Errorf("officekit: no extractable text found (the PDF may be scanned/image-only, or use an encoding this reader doesn't support)")
}
return result, nil
}
func inflateZlib(b []byte) ([]byte, error) {
r, err := zlib.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// pdfTextOpRe matches one text-showing operator call together with
// its operand(s): one or more PDF "literal strings" — parenthesized,
// with their own backslash-escape syntax decoded by
// pdfUnescapeLiteral — followed by Tj, TJ, ' or ". A TJ call's operand
// is actually an array, e.g. `[(Hel)-20(lo)] TJ`, which is why this
// matches "one or more" consecutive literals rather than exactly one.
var pdfTextOpRe = regexp.MustCompile(`(?s)((?:\((?:\\.|[^()\\])*\)[^()]*?)+)(Tj|TJ|'|")`)
// pdfLiteralRe pulls each individual (...) literal string out of one
// operator call's operand span.
var pdfLiteralRe = regexp.MustCompile(`(?s)\((?:\\.|[^()\\])*\)`)
// pdfNewlineOpRe matches the content-stream operators that move the
// text cursor to a new line (Td, TD, T*) — used only as a heuristic
// for where extractShownText inserts a newline into the recovered
// text, since the stream itself carries no notion of "paragraph" or
// "line" beyond cursor movement.
var pdfNewlineOpRe = regexp.MustCompile(`\bT[dD*]\b`)
// extractShownText walks one decompressed content stream in document
// order and concatenates the string(s) passed to each text-showing
// operator call, inserting a newline wherever a line-positioning
// operator (Td/TD/T*) appeared since the previous call and a plain
// space otherwise — an approximation of the stream's original line
// breaks, not a layout engine. A single TJ call's several literals
// (PDF splits one word across more than one literal to carry kerning
// adjustments, e.g. `[(Hel)-20(lo)] TJ`) are joined with nothing
// between them, since the gap is a spacing nudge, not a real word
// break — any actual space in the shown text already appears as a
// literal " " character the way it does in a Tj call.
func extractShownText(stream []byte) string {
var sb strings.Builder
pos := 0
pendingNewline := false
for pos < len(stream) {
loc := pdfTextOpRe.FindSubmatchIndex(stream[pos:])
if loc == nil {
break
}
matchStart, matchEnd := pos+loc[0], pos+loc[1]
if pdfNewlineOpRe.Match(stream[pos:matchStart]) {
pendingNewline = true
}
operand := stream[pos+loc[2] : pos+loc[3]]
var call strings.Builder
for _, lit := range pdfLiteralRe.FindAll(operand, -1) {
call.WriteString(pdfUnescapeLiteral(lit[1 : len(lit)-1]))
}
if text := call.String(); text != "" {
if sb.Len() > 0 {
if pendingNewline {
sb.WriteByte('\n')
} else {
sb.WriteByte(' ')
}
}
pendingNewline = false
sb.WriteString(text)
}
pos = matchEnd
}
return strings.TrimSpace(sb.String())
}
// pdfUnescapeLiteral decodes a PDF literal string's backslash escapes
// (\n \r \t \( \) \\ and octal \ddd) and treats every other byte as
// raw Latin-1 — correct for the common case (a simple, non-custom
// font encoding) and the best this package can do without parsing the
// PDF's font /Encoding and /ToUnicode tables.
func pdfUnescapeLiteral(b []byte) string {
var sb strings.Builder
for i := 0; i < len(b); i++ {
c := b[i]
if c != '\\' || i == len(b)-1 {
sb.WriteByte(c)
continue
}
i++
switch n := b[i]; {
case n == 'n':
sb.WriteByte('\n')
case n == 'r':
sb.WriteByte('\r')
case n == 't':
sb.WriteByte('\t')
case n == 'b' || n == 'f':
// backspace/form-feed: not meaningful in extracted text
case n == '(' || n == ')' || n == '\\':
sb.WriteByte(n)
case n == '\n':
// backslash-newline is a line continuation, not a literal char
case n >= '0' && n <= '7':
j := i
for j < len(b) && j < i+3 && b[j] >= '0' && b[j] <= '7' {
j++
}
if v, err := strconv.ParseInt(string(b[i:j]), 8, 32); err == nil {
sb.WriteByte(byte(v))
}
i = j - 1
default:
sb.WriteByte(n)
}
}
return sb.String()
}