Files
llmgw/internal/kb/extract/extract_test.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

324 lines
14 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
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"errors"
"os"
"strings"
"testing"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/unicode"
)
// zipOf builds a zip from name → content, in the order given.
func zipOf(t *testing.T, files ...string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for i := 0; i+1 < len(files); i += 2 {
f, err := w.Create(files[i])
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte(files[i+1])); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func extractOne(t *testing.T, name string, data []byte) string {
t.Helper()
parts, err := Bytes(context.Background(), name, data, Options{})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if len(parts) != 1 || parts[0].Err != nil {
t.Fatalf("%s: parts %+v", name, parts)
}
return parts[0].Text
}
func wants(t *testing.T, got string, want ...string) {
t.Helper()
for _, w := range want {
if !strings.Contains(got, w) {
t.Errorf("missing %q in:\n%s", w, got)
}
}
}
func TestTextInEveryEncodingBecomesUTF8(t *testing.T) {
const s = "Yıllık izin: Şubat'ta ğüşİöç"
u16, _ := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder().Bytes([]byte(s))
cp, _ := charmap.Windows1254.NewEncoder().Bytes([]byte(s))
for name, b := range map[string][]byte{
"utf8": []byte(s),
"utf8-bom": append([]byte{0xEF, 0xBB, 0xBF}, s...),
"utf16": u16,
"cp1254": cp,
"crlf": []byte(strings.ReplaceAll(s+"\nikinci", "\n", "\r\n")),
} {
got := extractOne(t, "a.txt", b)
if !strings.HasPrefix(got, s) {
t.Errorf("%s: got %q", name, got)
}
if strings.Contains(got, "\r") {
t.Errorf("%s: kept a CR", name)
}
}
// macOS writes names and sometimes text decomposed; the text comes out
// composed, so the same word is the same bytes everywhere.
if got := extractOne(t, "a.txt", []byte("Şirket")); !strings.HasPrefix(got, "Şirket") {
t.Errorf("NFD text came out as %q", got)
}
}
func TestHTMLKeepsHeadingsAndDropsScripts(t *testing.T) {
page := `<html><head><title>T</title><script>var x="gizli";</script></head><body>
<nav>menü</nav><h1>İzin Politikası</h1><p>Yıllık <b>izin</b> yirmi&nbsp;gün.</p>
<ul><li>Bir</li><li>İki</li></ul><table><tr><td>a</td><td>b</td></tr></table>
<p><b>Mer</b>haba</p></body></html>`
got := extractOne(t, "a.html", []byte(page))
wants(t, got, "# İzin Politikası", "Yıllık izin yirmi gün.", "- Bir", "- İki", "a\tb", "Merhaba")
for _, no := range []string{"gizli", "menü"} {
if strings.Contains(got, no) {
t.Errorf("%q leaked into:\n%s", no, got)
}
}
// Declared charset honoured.
l5, _ := charmap.ISO8859_9.NewEncoder().Bytes([]byte(`<meta charset="iso-8859-9"><p>Şirket</p>`))
wants(t, extractOne(t, "b.htm", l5), "Şirket")
}
func TestWordDocument(t *testing.T) {
doc := `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
<w:p><w:pPr><w:pStyle w:val="Balk1"/></w:pPr><w:r><w:t>Yıllık İzin</w:t></w:r></w:p>
<w:p><w:r><w:t xml:space="preserve">Çalışanlar </w:t></w:r><w:r><w:t>yirmi gün</w:t></w:r><w:del><w:r><w:delText>otuz</w:delText></w:r></w:del><w:r><w:tab/><w:t>izin alır.</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/></w:numPr></w:pPr><w:r><w:t>Madde bir</w:t></w:r></w:p>
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>Hücre1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Hücre2</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:t>Masraflar</w:t></w:r></w:p>
</w:body></w:document>`
got := extractOne(t, "a.docx", zipOf(t, "word/document.xml", doc,
"word/footnotes.xml", `<w:footnotes xmlns:w="x"><w:footnote><w:p><w:r><w:t>Dipnot</w:t></w:r></w:p></w:footnote></w:footnotes>`))
wants(t, got, "# Yıllık İzin", "Çalışanlar yirmi gün\tizin alır.", "- Madde bir", "Hücre1\tHücre2", "## Masraflar", "Dipnot")
if strings.Contains(got, "otuz") {
t.Errorf("a tracked deletion is in the text:\n%s", got)
}
}
func TestWorkbook(t *testing.T) {
got := extractOne(t, "a.xlsx", zipOf(t,
"xl/workbook.xml", `<workbook xmlns:r="r"><sheets><sheet name="Bütçe" sheetId="1" r:id="rId1"/><sheet name="Boş" sheetId="2" r:id="rId2"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels", `<Relationships><Relationship Id="rId1" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Target="worksheets/sheet2.xml"/></Relationships>`,
"xl/sharedStrings.xml", `<sst><si><t>Kalem</t></si><si><r><t>Tu</t></r><r><t>tar</t></r></si><si><t>Kira</t></si></sst>`,
"xl/worksheets/sheet1.xml", `<worksheet><sheetData><row><c t="s"><v>0</v></c><c t="s"><v>1</v></c></row><row><c t="s"><v>2</v></c><c><v>1500.5</v></c><c t="inlineStr"><is><t>not</t></is></c></row></sheetData></worksheet>`,
"xl/worksheets/sheet2.xml", `<worksheet><sheetData/></worksheet>`,
))
wants(t, got, "## Bütçe", "Kalem\tTutar", "Kira\t1500.5\tnot")
}
func TestPresentationInItsOwnOrderWithNotes(t *testing.T) {
slide := func(text string) string {
return `<p:sld xmlns:a="a" xmlns:p="p"><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>` + text +
`</a:t></a:r></a:p><a:p><a:fld type="slidenum"><a:t>9</a:t></a:fld></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`
}
got := extractOne(t, "a.pptx", zipOf(t,
"ppt/presentation.xml", `<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst><p:sldId id="256" r:id="rId3"/><p:sldId id="257" r:id="rId2"/></p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels", `<Relationships><Relationship Id="rId2" Target="slides/slide1.xml"/><Relationship Id="rId3" Target="slides/slide2.xml"/></Relationships>`,
"ppt/slides/slide1.xml", slide("İkinci slayt"),
"ppt/slides/slide2.xml", slide("Birinci slayt"),
"ppt/slides/_rels/slide2.xml.rels", `<Relationships><Relationship Id="rId1" Type="http://x/notesSlide" Target="../notesSlides/notesSlide7.xml"/></Relationships>`,
"ppt/notesSlides/notesSlide7.xml", slide("Konuşmacı notu"),
))
first, second := strings.Index(got, "Birinci slayt"), strings.Index(got, "İkinci slayt")
if first < 0 || second < 0 || first > second {
t.Errorf("slides out of the presentation's order:\n%s", got)
}
wants(t, got, "## Slide 1", "Notes:", "Konuşmacı notu")
if strings.Contains(got, "9") {
t.Errorf("a slide number field is in the text:\n%s", got)
}
}
func TestOpenDocument(t *testing.T) {
content := `<office:document-content xmlns:office="o" xmlns:text="t" xmlns:table="tb"><office:body><office:text>
<text:h text:outline-level="2">Bölüm</text:h><text:p>Bir<text:s text:c="2"/>iki<text:tab/>üç</text:p>
<text:list><text:list-item><text:p>madde</text:p></text:list-item></text:list>
<table:table table:name="Tablo1"><table:table-row><table:table-cell><text:p>x</text:p></table:table-cell><table:table-cell><text:p>y</text:p></table:table-cell></table:table-row></table:table>
</office:text></office:body></office:document-content>`
got := extractOne(t, "a.odt", zipOf(t, "mimetype", "application/vnd.oasis.opendocument.text", "content.xml", content))
wants(t, got, "## Bölüm", "Bir iki\tüç", "- madde", "## Tablo1", "x\ty")
wants(t, extractOne(t, "a.fodt", []byte(content)), "## Bölüm")
}
func TestRichText(t *testing.T) {
rtf := `{\rtf1\ansi\ansicpg1254\deff0{\fonttbl{\f0 Calibri;}}{\colortbl;\red0\green0\blue0;}` +
`{\*\generator Riched20;}\pard Y\'fdll\'fdk izin\par ` +
`\u350?irket \u-3913?\'3f ok\tab sonra\par{\header gizli}\cell b\row }`
got := extractOne(t, "a.rtf", []byte(rtf))
wants(t, got, "Yıllık izin", "Şirket", "ok\tsonra")
for _, no := range []string{"Calibri", "Riched", "gizli"} {
if strings.Contains(got, no) {
t.Errorf("%q leaked into:\n%s", no, got)
}
}
}
func TestEbookInSpineOrder(t *testing.T) {
got := extractOne(t, "a.epub", zipOf(t,
"mimetype", "application/epub+zip",
"META-INF/container.xml", `<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>`,
"OEBPS/content.opf", `<package><manifest><item id="c1" href="b.xhtml"/><item id="c2" href="a.xhtml"/></manifest><spine><itemref idref="c2"/><itemref idref="c1"/></spine></package>`,
"OEBPS/a.xhtml", `<html><body><h1>Önce</h1></body></html>`,
"OEBPS/b.xhtml", `<html><body><h1>Sonra</h1></body></html>`,
))
if a, b := strings.Index(got, "Önce"), strings.Index(got, "Sonra"); a < 0 || b < a {
t.Errorf("chapters out of spine order:\n%s", got)
}
}
func TestNotebookAndSubtitles(t *testing.T) {
nb := `{"metadata":{"kernelspec":{"language":"python"}},"cells":[{"cell_type":"markdown","source":["# Analiz\n","Veri"]},
{"cell_type":"code","source":"print(1)","outputs":[{"text":"çıktı"}]}]}`
got := extractOne(t, "a.ipynb", []byte(nb))
wants(t, got, "# Analiz", "```python\nprint(1)\n```")
if strings.Contains(got, "çıktı") {
t.Errorf("an output is in the text:\n%s", got)
}
srt := "1\n00:00:01,000 --> 00:00:02,000\n<i>Merhaba</i>\n\n2\n00:00:02,000 --> 00:00:03,000\nMerhaba\nDünya\n"
if got := extractOne(t, "a.srt", []byte(srt)); got != "Merhaba\nDünya\n" {
t.Errorf("subtitles came out as %q", got)
}
}
func TestPDFTextLayer(t *testing.T) {
b, err := os.ReadFile("testdata/turkish.pdf")
if err != nil {
t.Fatal(err)
}
wants(t, extractOne(t, "a.pdf", b), "Yıllık İzin Politikası", "Masraflar finans ekibine iletilir.")
// Garbage is a failure, not a panic.
if _, err := Bytes(context.Background(), "bad.pdf", []byte("%PDF-1.4 nonsense"), Options{}); err == nil {
t.Error("a broken PDF was read")
}
}
func TestArchivesAreReadMemberByMember(t *testing.T) {
inner := zipOf(t, "deep/c.md", "# Derin")
archive := zipOf(t,
"docs/a.txt", "Birinci",
`docs\b.html`, "<p>İkinci</p>",
"__MACOSX/docs/._a.txt", "çöp",
"docs/~$kilit.docx", "çöp",
"inner.zip", string(inner),
"image.bin", "\x00\x01",
)
parts, err := Bytes(context.Background(), "arsiv.zip", archive, Options{})
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, p := range parts {
if p.Err != nil {
t.Errorf("%s: %v", p.Name, p.Err)
}
got[p.Name] = p.Text
}
if len(got) != 3 || !strings.Contains(got["docs/a.txt"], "Birinci") || !strings.Contains(got["docs/b.html"], "İkinci") ||
!strings.Contains(got["inner.zip!/deep/c.md"], "# Derin") {
t.Errorf("archive parts %v", got)
}
}
func TestMailWithAttachments(t *testing.T) {
att := zipOf(t, "x.txt", "ekteki metin")
enc := func(b []byte) string {
s := base64.StdEncoding.EncodeToString(b)
var lines []string
for len(s) > 76 {
lines, s = append(lines, s[:76]), s[76:]
}
return strings.Join(append(lines, s), "\r\n")
}
msg := "From: =?UTF-8?B?QXnFn2U=?= <a@example.com>\r\n" +
"Subject: =?iso-8859-9?Q?=DEubat_raporu?=\r\n" +
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: multipart/alternative; boundary=C\r\n\r\n" +
"--C\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n" +
"Rapor ekte, te=C5=9Fekk=C3=BCrler.\r\n" +
"--C\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<p>HTML hali</p>\r\n--C--\r\n" +
"--B\r\nContent-Type: application/zip; name=\"ek.zip\"\r\nContent-Transfer-Encoding: base64\r\n" +
"Content-Disposition: attachment; filename=\"ek.zip\"\r\n\r\n" + enc(att) + "\r\n--B--\r\n"
parts, err := Bytes(context.Background(), "a.eml", []byte(msg), Options{})
if err != nil {
t.Fatal(err)
}
if len(parts) != 2 {
t.Fatalf("parts %+v", parts)
}
wants(t, parts[0].Text, "# Şubat raporu", "From: Ayşe", "Rapor ekte, teşekkürler.")
if strings.Contains(parts[0].Text, "HTML hali") {
t.Errorf("the HTML alternative was read beside the plain text:\n%s", parts[0].Text)
}
if parts[1].Name != "ek.zip!/x.txt" || !strings.Contains(parts[1].Text, "ekteki metin") {
t.Errorf("attachment %+v", parts[1])
}
}
func TestUnreadableFilesSayWhy(t *testing.T) {
_, err := Bytes(context.Background(), "a.doc", []byte{0xD0, 0xCF, 0x11, 0xE0, 0}, Options{})
if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), "LibreOffice") {
t.Errorf("a .doc without LibreOffice gave %v", err)
}
_, err = Bytes(context.Background(), "a.png", []byte{0x89, 'P', 'N', 'G', 0}, Options{})
if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), "Tesseract") {
t.Errorf("an image without Tesseract gave %v", err)
}
_, err = Bytes(context.Background(), "a.bin", []byte{0, 1, 2, 3}, Options{})
if !errors.Is(err, ErrUnsupported) {
t.Errorf("binary gave %v", err)
}
_, err = Bytes(context.Background(), "a.txt", []byte(" \n"), Options{})
if !errors.Is(err, ErrNoText) {
t.Errorf("an empty file gave %v", err)
}
}
func TestHelpersWhenInstalled(t *testing.T) {
h := FindHelpers()
ctx := context.Background()
if h.Tesseract != "" && h.PdfToPPM != "" {
// The text-layer PDF read by OCR instead: what a scanned one gets.
b, _ := os.ReadFile("testdata/turkish.pdf")
got, err := pdfWithHelpers(ctx, "a.pdf", b, Options{Helpers: Helpers{Tesseract: h.Tesseract, PdfToPPM: h.PdfToPPM}})
if err != nil {
t.Fatal(err)
}
wants(t, got, "Masraflar")
} else {
t.Log("no Tesseract with pdftoppm here; OCR not tried")
}
if h.Office == "" {
t.Log("no LibreOffice here; legacy Office formats not tried")
return
}
// A Word document converted to the legacy format and back would need a
// fixture made by Word; an ODT through the Office path proves the
// conversion runs and its result is read.
content := `<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p>LibreOffice yolu</text:p></office:text></office:body></office:document-content>`
got, err := h.officeToText(ctx, "a.fodt", []byte(`<?xml version="1.0"?>`+content), Options{})
if err != nil {
t.Fatal(err)
}
wants(t, got, "LibreOffice yolu")
}