Files
llmgw/internal/kb/extract/extract.go
T
Ilker ManapandClaude Opus 5.5 a0cfafe772 Text from documents of many formats, and chunks of it, for knowledge bases
PDF, Word, Excel, PowerPoint, OpenDocument, RTF, EPUB, HTML, MHTML, mail with
its attachments, notebooks, subtitles, zip archives and text and source files
in any common encoding, in pure Go; legacy Office formats through LibreOffice
and scanned pages through Tesseract when they are installed. Chunks of about
500 tokens under their headings, overlapping by a paragraph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:24:15 +02:00

297 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package extract turns documents into plain text for knowledge bases
// (BACKLOG F2), on the machine that holds them.
//
// The text it produces is Markdown-shaped: headings as "# ", "## ", list
// items as "- ", paragraphs separated by a blank line. The chunker splits on
// exactly those, so a format that knows its headings (DOCX styles, HTML h1–h6,
// ODT outline levels, slides, sheets) says so, and its chunks carry them.
//
// Built-in formats are pure Go, so the client builds for every system without
// a C toolchain. What Go cannot read — Word 97 files, scanned pages — goes to
// a helper when one is on the machine (LibreOffice, Tesseract, Poppler), and
// is otherwise skipped with a sentence saying which helper would read it.
package extract
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
// ErrUnsupported is a file this cannot read, with the reason wrapped around
// it ("a .doc needs LibreOffice").
var ErrUnsupported = errors.New("unsupported")
// ErrNoText is a file that was read and held no text: a scanned PDF without
// an OCR helper, an empty sheet.
var ErrNoText = errors.New("no text in it")
// Part is the text of one document. A file is one part; an archive or a mail
// with attachments is several, each named by its path inside, after the
// outer file's name and "!/".
type Part struct {
// Name is "" for the file itself, or the path inside it.
Name string
Text string
// Err is why this part was skipped, when it was; Text is then empty.
Err error
}
// Options are the limits and helpers extraction uses.
type Options struct {
Helpers Helpers
// MaxBytes is the largest file, or member of an archive, read; 0 is
// 256 MiB.
MaxBytes int64
// OCRLanguages are Tesseract's languages; "" is "tur+eng".
OCRLanguages string
}
func (o Options) maxBytes() int64 {
if o.MaxBytes > 0 {
return o.MaxBytes
}
return 256 << 20
}
// File extracts a file on disk.
func File(ctx context.Context, name string, o Options) ([]Part, error) {
st, err := os.Stat(name)
if err != nil {
return nil, err
}
if st.Size() > o.maxBytes() {
return nil, fmt.Errorf("%w: %d MiB is over the %d MiB limit", ErrUnsupported, st.Size()>>20, o.maxBytes()>>20)
}
data, err := os.ReadFile(name)
if err != nil {
return nil, err
}
return Bytes(ctx, filepath.Base(name), data, o)
}
// Bytes extracts a document held in memory; name gives its format.
func Bytes(ctx context.Context, name string, data []byte, o Options) ([]Part, error) {
return extractDepth(ctx, name, data, o, 0)
}
// maxDepth is how many archives deep members are followed: a zip in a mail
// in a zip, and no further.
const maxDepth = 3
func extractDepth(ctx context.Context, name string, data []byte, o Options, depth int) ([]Part, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
ext := Ext(name)
if f, ok := containers[ext]; ok {
if depth >= maxDepth {
return nil, fmt.Errorf("%w: archives nested more than %d deep", ErrUnsupported, maxDepth)
}
return f(ctx, name, data, o, depth)
}
text, err := one(ctx, name, ext, data, o)
if err != nil {
return nil, err
}
text = Clean(text)
if strings.TrimSpace(text) == "" {
return nil, ErrNoText
}
return []Part{{Text: text}}, nil
}
// one extracts a single document.
func one(ctx context.Context, name, ext string, data []byte, o Options) (string, error) {
if f, ok := builtin[ext]; ok {
text, err := f(data)
if err == nil && strings.TrimSpace(text) != "" {
return text, nil
}
// A PDF the Go reader cannot parse, or one with no text layer, is
// what the helpers are for.
if alt, ok := fallback[ext]; ok {
if t, aerr := alt(ctx, name, data, o); aerr == nil {
return t, nil
} else if err == nil {
err = aerr
}
}
if err == nil {
err = ErrNoText
}
return "", err
}
if alt, ok := fallback[ext]; ok {
return alt(ctx, name, data, o)
}
if looksLikeText(data) {
return decodeText(data), nil
}
if o.Helpers.Office != "" {
return o.Helpers.officeToText(ctx, name, data, o)
}
return "", fmt.Errorf("%w: no reader for %q files (LibreOffice, if installed, may read it)", ErrUnsupported, ext)
}
// Ext is a name's extension, lower case and without the dot.
func Ext(name string) string {
return strings.TrimPrefix(strings.ToLower(path.Ext(strings.ReplaceAll(name, `\`, "/"))), ".")
}
// Supported lists the extensions read without a helper, and those read with
// one, for --help and the docs.
func Supported() (builtIn, withHelper []string) {
for e := range builtin {
builtIn = append(builtIn, e)
}
for e := range containers {
builtIn = append(builtIn, e)
}
for e := range textExts {
builtIn = append(builtIn, e)
}
for e := range fallback {
if _, ok := builtin[e]; !ok {
withHelper = append(withHelper, e)
}
}
sort.Strings(builtIn)
sort.Strings(withHelper)
return builtIn, withHelper
}
// Known reports whether a name's extension is one this reads, with or without
// a helper, or one of the text formats.
func Known(name string) bool {
e := Ext(name)
_, a := builtin[e]
_, b := containers[e]
_, c := fallback[e]
return a || b || c || textExts[e]
}
var (
builtin map[string]func([]byte) (string, error)
containers map[string]func(context.Context, string, []byte, Options, int) ([]Part, error)
fallback map[string]func(context.Context, string, []byte, Options) (string, error)
)
func init() {
builtin = map[string]func([]byte) (string, error){
"pdf": pdfText,
"html": htmlText, "htm": htmlText, "xhtml": htmlText, "mht": mhtmlText, "mhtml": mhtmlText,
"docx": docxText, "docm": docxText, "dotx": docxText, "dotm": docxText,
"xlsx": xlsxText, "xlsm": xlsxText, "xltx": xlsxText, "xltm": xlsxText,
"pptx": pptxText, "pptm": pptxText, "ppsx": pptxText, "ppsm": pptxText, "potx": pptxText,
"odt": odfText, "ott": odfText, "ods": odfText, "ots": odfText, "odp": odfText, "otp": odfText,
"fodt": flatODFText, "fods": flatODFText, "fodp": flatODFText,
"rtf": rtfText,
"epub": epubText,
"ipynb": notebookText,
"srt": subtitleText, "vtt": subtitleText,
}
for e := range textExts {
builtin[e] = func(b []byte) (string, error) { return decodeText(b), nil }
}
containers = map[string]func(context.Context, string, []byte, Options, int) ([]Part, error){
"zip": zipParts,
"eml": mailParts,
}
fallback = map[string]func(context.Context, string, []byte, Options) (string, error){
"pdf": pdfWithHelpers,
"rtf": viaOffice,
}
for _, e := range officeOnly {
fallback[e] = viaOffice
}
for _, e := range imageExts {
fallback[e] = viaOCR
}
}
// textExts are formats that are text already: read as they are, decoded.
var textExts = map[string]bool{
"txt": true, "text": true, "md": true, "markdown": true, "mdx": true, "rst": true, "adoc": true,
"asciidoc": true, "org": true, "tex": true, "log": true, "csv": true, "tsv": true, "json": true,
"jsonl": true, "ndjson": true, "yaml": true, "yml": true, "toml": true, "ini": true, "cfg": true,
"conf": true, "properties": true, "env": true, "xml": true, "svg": true, "sql": true,
"go": true, "py": true, "js": true, "mjs": true, "cjs": true, "ts": true, "tsx": true, "jsx": true,
"java": true, "kt": true, "kts": true, "scala": true, "c": true, "h": true, "cc": true, "cpp": true,
"cxx": true, "hpp": true, "hh": true, "cs": true, "fs": true, "vb": true, "rs": true, "swift": true,
"m": true, "mm": true, "rb": true, "php": true, "pl": true, "pm": true, "lua": true, "r": true,
"jl": true, "dart": true, "ex": true, "exs": true, "erl": true, "hs": true, "clj": true,
"sh": true, "bash": true, "zsh": true, "fish": true, "ps1": true, "psm1": true, "bat": true,
"cmd": true, "groovy": true, "gradle": true, "tf": true, "hcl": true, "proto": true,
"graphql": true, "gql": true, "css": true, "scss": true, "sass": true, "less": true,
"vue": true, "svelte": true, "dockerfile": true, "makefile": true, "mk": true, "cmake": true,
"bib": true, "nfo": true, "diff": true, "patch": true,
}
// officeOnly are formats only LibreOffice reads here: the binary Office
// formats before 2007, and others it opens.
var officeOnly = []string{
"doc", "dot", "xls", "xlt", "xlsb", "ppt", "pps", "pot", "wpd", "wps", "wri",
"pages", "numbers", "key", "sxw", "sxc", "sxi", "hwp", "pub", "vsd", "vsdx",
}
// imageExts are read by OCR.
var imageExts = []string{"png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "webp", "jp2", "pnm"}
// looksLikeText reports whether bytes are text: no NUL in the first 8 KiB
// (UTF-16 has its BOM checked first), and mostly printable.
func looksLikeText(b []byte) bool {
if hasUTF16BOM(b) {
return true
}
head := b[:min(len(b), 8192)]
if bytes.IndexByte(head, 0) >= 0 {
return false
}
if utf8.Valid(head) {
return true
}
// A single-byte code page: allow it when control characters are rare.
ctl := 0
for _, c := range head {
if c < 0x20 && c != '\n' && c != '\r' && c != '\t' && c != '\f' {
ctl++
}
}
return ctl*100 < len(head)
}
var (
spaceRun = regexp.MustCompile(`[ \t\x{00A0}]+\n`)
blankRun = regexp.MustCompile(`\n{3,}`)
)
// Clean normalises extracted text: LF line ends, no trailing spaces, no more
// than one blank line in a row, no control characters but tab and newline.
func Clean(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.Map(func(r rune) rune {
switch {
case r == '\n' || r == '\t':
return r
case r == '\f' || r == '\v':
return '\n'
case r < 0x20 || r == 0x7f || r == utf8.RuneError || r == '\uFEFF':
return -1
}
return r
}, s)
s = spaceRun.ReplaceAllString(s, "\n")
s = blankRun.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s) + "\n"
}