This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git


The following commit(s) were added to refs/heads/main by this push:
     new 733f389d feat: support PDF uploads for RAG documents (#356)
733f389d is described below

commit 733f389df6d3b2d065b873765b986b1719e79e14
Author: Nannan Wang <[email protected]>
AuthorDate: Mon Jun 8 12:29:19 2026 +0800

    feat: support PDF uploads for RAG documents (#356)
    
    ### Summary
    
    Support text-based PDF uploads in the RAG document upload path.
    
    ### Changes
    
    * Add PDF text extraction support in `read_documents()`
    * Use `pypdf` to extract text from PDF files page by page
    * Handle encrypted, unreadable, and scanned-image-only PDFs with clear
    Gradio errors
    * Keep existing TXT and DOCX behavior unchanged
    * Update the demo upload copy to mention TXT, DOCX, and PDF
    
    ### Test
    
    * `python -m py_compile
    hugegraph-llm/src/hugegraph_llm/utils/vector_index_utils.py`
    
    Closes #345
    
    ---------
    
    Co-authored-by: nannan-2026 <[email protected]>
    Co-authored-by: imbajin <[email protected]>
---
 hugegraph-llm/pyproject.toml                       |   1 +
 .../demo/rag_demo/vector_graph_block.py            |   2 +-
 .../src/hugegraph_llm/utils/vector_index_utils.py  |  41 ++-
 .../src/tests/document/test_vector_index_utils.py  | 288 +++++++++++++++++++++
 pyproject.toml                                     |   1 +
 5 files changed, 326 insertions(+), 7 deletions(-)

diff --git a/hugegraph-llm/pyproject.toml b/hugegraph-llm/pyproject.toml
index 5b2c9f5d..cd2452d9 100644
--- a/hugegraph-llm/pyproject.toml
+++ b/hugegraph-llm/pyproject.toml
@@ -53,6 +53,7 @@ dependencies = [
     "gradio",
     "jieba",
     "python-docx",
+    "pypdf",
     "langchain-text-splitters",
     "faiss-cpu",
     "python-dotenv",
diff --git 
a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py 
b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py
index 5dbd87c4..6816d9f4 100644
--- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py
+++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py
@@ -216,7 +216,7 @@ def create_vector_graph_block():
             """## Build Vector/Graph Index & Extract Knowledge Graph
     - Docs:
         - text: Build rag index from plain text
-        - file: Upload file(s) which should be <u>TXT</u> or <u>.docx</u> 
(Multiple files can be selected together)
+        - file: Upload file(s) which should be <u>TXT</u>, <u>DOCX</u>, or 
<u>PDF</u> (Multiple files can be selected together)
     - [Schema](https://hugegraph.apache.org/docs/clients/restful-api/schema/): 
(Accept **2 types**)
         - User-defined Schema (JSON format, follow the 
[template](https://github.com/apache/hugegraph-ai/blob/aff3bbe25fa91c3414947a196131be812c20ef11/hugegraph-llm/src/hugegraph_llm/config/config_data.py#L125)
         to modify it)
diff --git a/hugegraph-llm/src/hugegraph_llm/utils/vector_index_utils.py 
b/hugegraph-llm/src/hugegraph_llm/utils/vector_index_utils.py
index bb9536d2..7475cdd0 100644
--- a/hugegraph-llm/src/hugegraph_llm/utils/vector_index_utils.py
+++ b/hugegraph-llm/src/hugegraph_llm/utils/vector_index_utils.py
@@ -16,10 +16,12 @@
 # under the License.
 
 import json
+from pathlib import Path
 from typing import Type
 
 import docx
 import gradio as gr
+from pypdf import PdfReader
 
 from hugegraph_llm.config import huge_settings, index_settings
 from hugegraph_llm.flows.scheduler import SchedulerSingleton
@@ -28,6 +30,33 @@ from hugegraph_llm.indices.vector_index.faiss_vector_store 
import FaissVectorInd
 from hugegraph_llm.models.embeddings.init_embedding import Embeddings
 
 
+def read_pdf_text(full_path: str) -> str:
+    try:
+        with open(full_path, "rb") as pdf_file:
+            reader = PdfReader(pdf_file)
+
+            if reader.is_encrypted:
+                raise gr.Error("Encrypted PDF files are not supported. Please 
upload an unencrypted PDF.")
+
+            page_texts = []
+            for page in reader.pages:
+                page_text = page.extract_text() or ""
+                if page_text.strip():
+                    page_texts.append(page_text)
+
+            text = "\n".join(page_texts).strip()
+            if not text:
+                raise gr.Error(
+                    "No extractable text was found in this PDF. Scanned-image 
PDFs are not supported without OCR."
+                )
+
+            return text
+    except gr.Error:
+        raise
+    except Exception as exc:
+        raise gr.Error(f"Failed to read PDF file: {exc}") from exc
+
+
 def read_documents(input_file, input_text):
     if input_text:
         texts = [input_text]
@@ -35,21 +64,21 @@ def read_documents(input_file, input_text):
         texts = []
         for file in input_file:
             full_path = file.name
-            if full_path.endswith(".txt"):
+            suffix = Path(full_path).suffix.lower()
+            if suffix == ".txt":
                 with open(full_path, "r", encoding="utf-8") as f:
                     texts.append(f.read())
-            elif full_path.endswith(".docx"):
+            elif suffix == ".docx":
                 text = ""
                 doc = docx.Document(full_path)
                 for para in doc.paragraphs:
                     text += para.text
                     text += "\n"
                 texts.append(text)
-            elif full_path.endswith(".pdf"):
-                # TODO: support PDF file
-                raise gr.Error("PDF will be supported later! Try to upload 
text/docx now")
+            elif suffix == ".pdf":
+                texts.append(read_pdf_text(full_path))
             else:
-                raise gr.Error("Please input txt or docx file.")
+                raise gr.Error("Please input txt, docx, or pdf file.")
     else:
         raise gr.Error("Please input text or upload file.")
     return texts
diff --git a/hugegraph-llm/src/tests/document/test_vector_index_utils.py 
b/hugegraph-llm/src/tests/document/test_vector_index_utils.py
new file mode 100644
index 00000000..33213cdc
--- /dev/null
+++ b/hugegraph-llm/src/tests/document/test_vector_index_utils.py
@@ -0,0 +1,288 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from types import SimpleNamespace
+
+import gradio as gr
+import pytest
+from docx import Document
+from pypdf import PdfWriter
+
+from hugegraph_llm.utils import graph_index_utils, vector_index_utils
+from hugegraph_llm.utils.vector_index_utils import read_documents
+
+
+def _build_pdf(content_stream: bytes) -> bytes:
+    objects = [
+        b"<< /Type /Catalog /Pages 2 0 R >>",
+        b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+        (
+            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
+            b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"
+        ),
+        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+        (b"<< /Length " + str(len(content_stream)).encode() + b" >>\nstream\n" 
+ content_stream + b"\nendstream"),
+    ]
+
+    pdf = b"%PDF-1.4\n"
+    offsets = []
+    for index, obj in enumerate(objects, start=1):
+        offsets.append(len(pdf))
+        pdf += f"{index} 0 obj\n".encode()
+        pdf += obj + b"\nendobj\n"
+
+    xref_offset = len(pdf)
+    pdf += f"xref\n0 {len(objects) + 1}\n".encode()
+    pdf += b"0000000000 65535 f \n"
+    for offset in offsets:
+        pdf += f"{offset:010d} 00000 n \n".encode()
+
+    pdf += (f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R 
>>\nstartxref\n{xref_offset}\n%%EOF\n").encode()
+    return pdf
+
+
+def test_read_documents_reads_txt_file(tmp_path):
+    txt_path = tmp_path / "sample.txt"
+    txt_path.write_text("hello hugegraph", encoding="utf-8")
+
+    result = read_documents([SimpleNamespace(name=str(txt_path))], "")
+
+    assert result == ["hello hugegraph"]
+
+
+def _escape_pdf_text(text):
+    return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
+
+
+def _build_multi_page_pdf(page_texts):
+    page_count = len(page_texts)
+    page_object_start = 4
+    content_object_start = page_object_start + page_count
+    kids = " ".join(f"{page_object_start + index} 0 R" for index in 
range(page_count))
+
+    objects = [
+        b"<< /Type /Catalog /Pages 2 0 R >>",
+        f"<< /Type /Pages /Kids [{kids}] /Count {page_count} >>".encode(),
+        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+    ]
+
+    for index in range(page_count):
+        content_ref = content_object_start + index
+        objects.append(
+            (
+                "<< /Type /Page /Parent 2 0 R "
+                "/Resources << /Font << /F1 3 0 R >> >> "
+                f"/Contents {content_ref} 0 R "
+                "/MediaBox [0 0 612 792] >>"
+            ).encode()
+        )
+
+    for page_text in page_texts:
+        content_stream = (f"BT /F1 24 Tf 72 720 Td 
({_escape_pdf_text(page_text)}) Tj ET").encode()
+        objects.append(
+            b"<< /Length " + str(len(content_stream)).encode() + b" 
>>\nstream\n" + content_stream + b"\nendstream"
+        )
+
+    pdf = b"%PDF-1.4\n"
+    offsets = []
+    for object_id, pdf_object in enumerate(objects, start=1):
+        offsets.append(len(pdf))
+        pdf += f"{object_id} 0 obj\n".encode()
+        pdf += pdf_object + b"\nendobj\n"
+
+    xref_offset = len(pdf)
+    pdf += f"xref\n0 {len(objects) + 1}\n".encode()
+    pdf += b"0000000000 65535 f \n"
+    for offset in offsets:
+        pdf += f"{offset:010d} 00000 n \n".encode()
+
+    pdf += (f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R 
>>\nstartxref\n{xref_offset}\n%%EOF\n").encode()
+    return pdf
+
+
+def test_read_documents_reads_pdf_file(tmp_path):
+    pdf_path = tmp_path / "sample.pdf"
+    pdf_path.write_bytes(_build_pdf(b"BT /F1 24 Tf 100 700 Td (Hello HugeGraph 
PDF) Tj ET"))
+
+    result = read_documents([SimpleNamespace(name=str(pdf_path))], "")
+
+    assert "Hello HugeGraph PDF" in result[0]
+
+
+def test_read_documents_reads_mixed_case_pdf_file(tmp_path):
+    pdf_path = tmp_path / "sample.PDF"
+    pdf_path.write_bytes(_build_pdf(b"BT /F1 24 Tf 100 700 Td (Mixed Case PDF) 
Tj ET"))
+
+    result = read_documents([SimpleNamespace(name=str(pdf_path))], "")
+
+    assert "Mixed Case PDF" in result[0]
+
+
+def test_read_documents_rejects_pdf_without_extractable_text(tmp_path):
+    pdf_path = tmp_path / "empty.pdf"
+    pdf_path.write_bytes(_build_pdf(b""))
+
+    with pytest.raises(gr.Error, match="No extractable text"):
+        read_documents([SimpleNamespace(name=str(pdf_path))], "")
+
+
+class BrokenPdfReader:
+    def __init__(self, _):
+        raise ValueError("broken pdf")
+
+
+def test_read_documents_reads_docx_file(tmp_path):
+    docx_path = tmp_path / "sample.docx"
+    document = Document()
+    document.add_paragraph("hello docx")
+    document.save(docx_path)
+
+    result = read_documents([SimpleNamespace(name=str(docx_path))], "")
+
+    assert "hello docx" in result[0]
+
+
+def test_read_documents_returns_clear_error_for_unreadable_pdf(monkeypatch, 
tmp_path):
+    pdf_path = tmp_path / "broken.pdf"
+    pdf_path.write_bytes(b"not a valid pdf")
+
+    monkeypatch.setattr(vector_index_utils, "PdfReader", BrokenPdfReader)
+
+    with pytest.raises(gr.Error, match="Failed to read PDF file"):
+        read_documents([SimpleNamespace(name=str(pdf_path))], "")
+
+
+class EncryptedPdfReader:
+    is_encrypted = True
+
+    def __init__(self, _):
+        self.pages = []
+
+
+def test_read_documents_returns_clear_error_for_encrypted_pdf(monkeypatch, 
tmp_path):
+    pdf_path = tmp_path / "encrypted.pdf"
+    pdf_path.write_bytes(b"%PDF-1.4\n")
+
+    monkeypatch.setattr(vector_index_utils, "PdfReader", EncryptedPdfReader)
+
+    with pytest.raises(gr.Error, match="Encrypted PDF files are not 
supported"):
+        read_documents([SimpleNamespace(name=str(pdf_path))], "")
+
+
+def test_read_documents_rejects_unsupported_file_type(tmp_path):
+    markdown_path = tmp_path / "sample.md"
+    markdown_path.write_text("hello markdown", encoding="utf-8")
+
+    with pytest.raises(gr.Error, match="Please input txt, docx, or pdf file"):
+        read_documents([SimpleNamespace(name=str(markdown_path))], "")
+
+
+class DummyScheduler:
+    def __init__(self):
+        self.calls = []
+
+    def schedule_flow(self, *args):
+        self.calls.append(args)
+        return "scheduled"
+
+
+def test_build_vector_index_accepts_pdf_upload_and_forwards_text(monkeypatch, 
tmp_path):
+    pdf_path = tmp_path / "entrypoint.pdf"
+    pdf_path.write_bytes(_build_pdf(b"BT /F1 24 Tf 100 700 Td (Build Vector 
Entrypoint PDF) Tj ET"))
+    scheduler = DummyScheduler()
+
+    monkeypatch.setattr(
+        vector_index_utils.SchedulerSingleton,
+        "get_instance",
+        lambda: scheduler,
+    )
+
+    result = vector_index_utils.build_vector_index(
+        [SimpleNamespace(name=str(pdf_path))],
+        "",
+    )
+
+    assert result == "scheduled"
+    assert len(scheduler.calls) == 1
+
+    flow_name, texts = scheduler.calls[0]
+    assert flow_name == "build_vector_index"
+    assert "Build Vector Entrypoint PDF" in texts[0]
+
+
+def test_extract_graph_accepts_pdf_upload_and_forwards_text(monkeypatch, 
tmp_path):
+    pdf_path = tmp_path / "graph_entrypoint.pdf"
+    pdf_path.write_bytes(_build_pdf(b"BT /F1 24 Tf 100 700 Td (Extract Graph 
Entrypoint PDF) Tj ET"))
+    schema = '{"vertices": [], "edges": []}'
+    example_prompt = "Extract graph data."
+    scheduler = DummyScheduler()
+
+    monkeypatch.setattr(
+        graph_index_utils.SchedulerSingleton,
+        "get_instance",
+        lambda: scheduler,
+    )
+
+    result = graph_index_utils.extract_graph(
+        [SimpleNamespace(name=str(pdf_path))],
+        "",
+        schema,
+        example_prompt,
+    )
+
+    assert result == "scheduled"
+    assert len(scheduler.calls) == 1
+
+    flow_name, forwarded_schema, texts, forwarded_prompt, graph_mode = 
scheduler.calls[0]
+    assert flow_name == graph_index_utils.FlowName.GRAPH_EXTRACT
+    assert forwarded_schema == schema
+    assert "Extract Graph Entrypoint PDF" in texts[0]
+    assert forwarded_prompt == example_prompt
+    assert graph_mode == "property_graph"
+
+
+def test_read_documents_rejects_encrypted_pdf(tmp_path):
+    pdf_path = tmp_path / "encrypted.pdf"
+
+    writer = PdfWriter()
+    writer.add_blank_page(width=72, height=72)
+    writer.encrypt("secret")
+    with pdf_path.open("wb") as pdf_file:
+        writer.write(pdf_file)
+
+    with pytest.raises(gr.Error) as exc_info:
+        
vector_index_utils.read_documents([SimpleNamespace(name=str(pdf_path))], "")
+
+    assert "PDF" in str(exc_info.value)
+
+
+def test_read_documents_preserves_multi_page_pdf_order(tmp_path):
+    pdf_path = tmp_path / "multi_page.pdf"
+    pdf_path.write_bytes(
+        _build_multi_page_pdf(
+            [
+                "First page PDF text",
+                "Second page PDF text",
+                "Third page PDF text",
+            ]
+        )
+    )
+
+    result = 
vector_index_utils.read_documents([SimpleNamespace(name=str(pdf_path))], "")
+
+    assert len(result) == 1
+    extracted_text = result[0]
+    assert extracted_text.index("First page PDF text") < 
extracted_text.index("Second page PDF text")
+    assert extracted_text.index("Second page PDF text") < 
extracted_text.index("Third page PDF text")
diff --git a/pyproject.toml b/pyproject.toml
index 2687ce93..c2feda54 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -119,6 +119,7 @@ constraint-dependencies = [
     "gradio~=5.20.0",
     "jieba~=0.42.1",
     "python-docx~=1.1.2",
+    "pypdf~=6.12.0",
     "langchain-text-splitters~=0.2.2",
     "faiss-cpu~=1.8.0",
     "python-dotenv~=1.0.1",

Reply via email to