// 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() }