Skip to content

Commit f8cacaf

Browse files
committed
add pdf and docx for knowledge bases
1 parent 804f4b5 commit f8cacaf

5 files changed

Lines changed: 210 additions & 4 deletions

File tree

src/backend/base/langflow/api/utils/kb_helpers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from langchain_chroma import Chroma
1616
from langchain_core.documents import Document
1717
from langchain_text_splitters import RecursiveCharacterTextSplitter
18+
from lfx.base.data.utils import extract_text_from_bytes
1819
from lfx.base.models.unified_models import get_embedding_model_options
1920
from lfx.components.models_and_agents.embedding_model import EmbeddingModelComponent
2021
from lfx.log import logger
@@ -330,7 +331,7 @@ async def perform_ingestion(
330331
job_id_str = str(task_job_id)
331332
for file_name, file_content in files_data:
332333
await logger.ainfo("Starting ingestion of %s for %s", file_name, kb_name)
333-
content = file_content.decode("utf-8", errors="ignore")
334+
content = extract_text_from_bytes(file_name, file_content)
334335
if not content.strip():
335336
continue
336337

src/backend/base/langflow/api/v1/knowledge_bases.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
1313
from langchain_chroma import Chroma
1414
from langchain_text_splitters import RecursiveCharacterTextSplitter
15+
from lfx.base.data.utils import extract_text_from_bytes
1516
from lfx.log import logger
1617

1718
from langflow.api.utils import CurrentActiveUser
@@ -170,7 +171,7 @@ async def preview_chunks(
170171
try:
171172
file_content = await uploaded_file.read()
172173
file_name = uploaded_file.filename or "unknown"
173-
text_content = file_content.decode("utf-8", errors="ignore")
174+
text_content = extract_text_from_bytes(file_name, file_content)
174175

175176
if not text_content.strip():
176177
file_previews.append(
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
from io import BytesIO
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
from pypdf import PdfWriter
6+
7+
from lfx.base.data.utils import extract_text_from_bytes
8+
9+
10+
def _make_blank_pdf(num_pages: int = 1) -> bytes:
11+
"""Create a valid PDF with blank pages."""
12+
writer = PdfWriter()
13+
for _ in range(num_pages):
14+
writer.add_blank_page(width=612, height=792)
15+
buf = BytesIO()
16+
writer.write(buf)
17+
return buf.getvalue()
18+
19+
20+
def _mock_pdf_reader(pages_text: list[str]):
21+
"""Create a mock PdfReader that returns pages with given text."""
22+
mock_reader = MagicMock()
23+
mock_pages = []
24+
for text in pages_text:
25+
page = MagicMock()
26+
page.extract_text.return_value = text
27+
mock_pages.append(page)
28+
mock_reader.pages = mock_pages
29+
mock_reader.__enter__ = MagicMock(return_value=mock_reader)
30+
mock_reader.__exit__ = MagicMock(return_value=False)
31+
return mock_reader
32+
33+
34+
class TestExtractTextFromBytesPDF:
35+
@patch("lfx.base.data.utils.PdfReader")
36+
def test_should_extract_text_from_valid_pdf(self, mock_reader_cls):
37+
mock_reader_cls.return_value = _mock_pdf_reader(["Hello World"])
38+
result = extract_text_from_bytes("document.pdf", _make_blank_pdf())
39+
assert "Hello World" in result
40+
41+
@patch("lfx.base.data.utils.PdfReader")
42+
def test_should_extract_text_from_multi_page_pdf(self, mock_reader_cls):
43+
mock_reader_cls.return_value = _mock_pdf_reader(["Page one content", "Page two content"])
44+
result = extract_text_from_bytes("multi.pdf", _make_blank_pdf(2))
45+
assert "Page one content" in result
46+
assert "Page two content" in result
47+
48+
@patch("lfx.base.data.utils.PdfReader")
49+
def test_should_join_pages_with_double_newline(self, mock_reader_cls):
50+
mock_reader_cls.return_value = _mock_pdf_reader(["First", "Second"])
51+
result = extract_text_from_bytes("test.pdf", _make_blank_pdf(2))
52+
assert result == "First\n\nSecond"
53+
54+
@patch("lfx.base.data.utils.PdfReader")
55+
def test_should_be_case_insensitive_on_extension(self, mock_reader_cls):
56+
mock_reader_cls.return_value = _mock_pdf_reader(["Test"])
57+
result = extract_text_from_bytes("DOC.PDF", _make_blank_pdf())
58+
assert "Test" in result
59+
60+
def test_should_raise_value_error_for_corrupted_pdf(self):
61+
with pytest.raises(ValueError, match="Failed to parse PDF file"):
62+
extract_text_from_bytes("bad.pdf", b"this is not a pdf")
63+
64+
def test_should_raise_value_error_for_empty_pdf_bytes(self):
65+
with pytest.raises(ValueError, match="Failed to parse PDF file"):
66+
extract_text_from_bytes("empty.pdf", b"")
67+
68+
def test_should_handle_pdf_with_blank_pages(self):
69+
result = extract_text_from_bytes("blank.pdf", _make_blank_pdf())
70+
assert isinstance(result, str)
71+
72+
@patch("lfx.base.data.utils.PdfReader")
73+
def test_should_handle_page_returning_none(self, mock_reader_cls):
74+
mock_reader_cls.return_value = _mock_pdf_reader(["Text"])
75+
mock_reader_cls.return_value.pages[0].extract_text.return_value = None
76+
mock_reader_cls.return_value.__enter__.return_value = mock_reader_cls.return_value
77+
result = extract_text_from_bytes("null_page.pdf", _make_blank_pdf())
78+
assert isinstance(result, str)
79+
80+
81+
class TestExtractTextFromBytesDOCX:
82+
def test_should_extract_text_from_valid_docx(self):
83+
from docx import Document
84+
85+
doc = Document()
86+
doc.add_paragraph("Hello from DOCX")
87+
buf = BytesIO()
88+
doc.save(buf)
89+
90+
result = extract_text_from_bytes("file.docx", buf.getvalue())
91+
assert "Hello from DOCX" in result
92+
93+
def test_should_extract_multiple_paragraphs(self):
94+
from docx import Document
95+
96+
doc = Document()
97+
doc.add_paragraph("First paragraph")
98+
doc.add_paragraph("Second paragraph")
99+
buf = BytesIO()
100+
doc.save(buf)
101+
102+
result = extract_text_from_bytes("file.docx", buf.getvalue())
103+
assert "First paragraph" in result
104+
assert "Second paragraph" in result
105+
assert "\n\n" in result
106+
107+
def test_should_be_case_insensitive_on_extension(self):
108+
from docx import Document
109+
110+
doc = Document()
111+
doc.add_paragraph("Case test")
112+
buf = BytesIO()
113+
doc.save(buf)
114+
115+
result = extract_text_from_bytes("FILE.DOCX", buf.getvalue())
116+
assert "Case test" in result
117+
118+
def test_should_raise_value_error_for_corrupted_docx(self):
119+
with pytest.raises(ValueError, match="Failed to parse DOCX file"):
120+
extract_text_from_bytes("bad.docx", b"not a valid docx")
121+
122+
def test_should_raise_value_error_for_empty_docx_bytes(self):
123+
with pytest.raises(ValueError, match="Failed to parse DOCX file"):
124+
extract_text_from_bytes("empty.docx", b"")
125+
126+
def test_should_handle_docx_with_no_paragraphs(self):
127+
from docx import Document
128+
129+
doc = Document()
130+
buf = BytesIO()
131+
doc.save(buf)
132+
133+
result = extract_text_from_bytes("empty_doc.docx", buf.getvalue())
134+
assert isinstance(result, str)
135+
136+
137+
class TestExtractTextFromBytesPlainText:
138+
def test_should_decode_utf8_text(self):
139+
content = "Hello plain text".encode("utf-8")
140+
result = extract_text_from_bytes("readme.txt", content)
141+
assert result == "Hello plain text"
142+
143+
def test_should_handle_non_utf8_gracefully(self):
144+
content = b"\xff\xfe\x00\x01 some text"
145+
result = extract_text_from_bytes("binary.txt", content)
146+
assert isinstance(result, str)
147+
assert "some text" in result
148+
149+
def test_should_handle_empty_content(self):
150+
result = extract_text_from_bytes("empty.txt", b"")
151+
assert result == ""
152+
153+
def test_should_handle_csv_as_plain_text(self):
154+
content = "col1,col2\nval1,val2".encode("utf-8")
155+
result = extract_text_from_bytes("data.csv", content)
156+
assert "col1,col2" in result
157+
158+
def test_should_handle_json_as_plain_text(self):
159+
content = '{"key": "value"}'.encode("utf-8")
160+
result = extract_text_from_bytes("data.json", content)
161+
assert '"key"' in result
162+
163+
def test_should_handle_unknown_extension_as_plain_text(self):
164+
content = "some content".encode("utf-8")
165+
result = extract_text_from_bytes("file.xyz", content)
166+
assert result == "some content"
167+
168+
def test_should_handle_file_without_extension(self):
169+
content = "no extension".encode("utf-8")
170+
result = extract_text_from_bytes("Makefile", content)
171+
assert result == "no extension"
172+
173+
def test_should_preserve_unicode_characters(self):
174+
content = "café résumé naïve".encode("utf-8")
175+
result = extract_text_from_bytes("unicode.txt", content)
176+
assert result == "café résumé naïve"

src/frontend/src/modals/knowledgeBaseUploadModal/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export const KB_INGEST_FORMATS: Record<string, string[]> = {
2626
"adoc",
2727
"asciidoc",
2828
"asc",
29+
"pdf",
30+
"docx",
2931
],
3032
spreadsheets: ["csv"],
3133
code: ["py", "js", "ts", "tsx", "sh", "sql"],

src/lfx/src/lfx/base/data/utils.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,35 @@ async def read_docx_file_async(file_path: str) -> str:
247247
Path(temp_path).unlink()
248248

249249

250-
def parse_pdf_to_text(file_path: str) -> str:
251-
from pypdf import PdfReader
250+
def extract_text_from_bytes(file_name: str, file_content: bytes) -> str:
251+
"""Extract text from binary file content based on file extension.
252+
253+
Supports PDF (via pypdf), DOCX (via python-docx), and plain text files.
254+
255+
Raises:
256+
ValueError: If the file content is corrupted or cannot be parsed.
257+
"""
258+
lower_name = file_name.lower()
259+
if lower_name.endswith(".pdf"):
260+
try:
261+
with BytesIO(file_content) as f, PdfReader(f) as reader:
262+
return "\n\n".join(page.extract_text() or "" for page in reader.pages)
263+
except Exception as e:
264+
msg = f"Failed to parse PDF file '{file_name}': {e}"
265+
raise ValueError(msg) from e
266+
if lower_name.endswith(".docx"):
267+
try:
268+
from docx import Document
269+
270+
doc = Document(BytesIO(file_content))
271+
return "\n\n".join(p.text for p in doc.paragraphs)
272+
except Exception as e:
273+
msg = f"Failed to parse DOCX file '{file_name}': {e}"
274+
raise ValueError(msg) from e
275+
return file_content.decode("utf-8", errors="ignore")
252276

277+
278+
def parse_pdf_to_text(file_path: str) -> str:
253279
with Path(file_path).open("rb") as f, PdfReader(f) as reader:
254280
return "\n\n".join([page.extract_text() for page in reader.pages])
255281

0 commit comments

Comments
 (0)