feat(reader): add PDF reading support with outline chapters, CLI blocks, and noise filtering (task 7)
This commit is contained in:
1 parent
c38b1dae0a
commit
6dcfd59f73
67 files changed
+49444
-40
No files matched your search
@@ -891,3 +891,123 @@ def test_duplicate_prevention_and_in_library_flag():
|
||||
data_new = resp_lookup_new.json()
|
||||
assert data_new["in_library"] is False
|
||||
assert data_new["library_card"] is None
|
||||
|
||||
|
||||
def make_mock_pdf(title="CCNA 200-301 Official Guide", author="Wendell Odom", with_outline=True):
|
||||
import io
|
||||
import pypdf
|
||||
from pypdf.generic import DecodedStreamObject, NameObject, DictionaryObject
|
||||
|
||||
writer = pypdf.PdfWriter()
|
||||
|
||||
def add_text_page(text_lines):
|
||||
page = writer.add_blank_page(width=400, height=400)
|
||||
stream_content = [b"BT /F1 12 Tf 50 350 Td"]
|
||||
for idx, l in enumerate(text_lines):
|
||||
if idx > 0:
|
||||
stream_content.append(b"0 -15 Td")
|
||||
escaped = l.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||
stream_content.append(f"({escaped}) Tj".encode("latin1"))
|
||||
stream_content.append(b"ET")
|
||||
|
||||
content = DecodedStreamObject()
|
||||
content.set_data(b" ".join(stream_content))
|
||||
page[NameObject("/Contents")] = content
|
||||
page[NameObject("/Resources")] = DictionaryObject({
|
||||
NameObject("/Font"): DictionaryObject({
|
||||
NameObject("/F1"): DictionaryObject({
|
||||
NameObject("/Type"): NameObject("/Font"),
|
||||
NameObject("/Subtype"): NameObject("/Type1"),
|
||||
NameObject("/BaseFont"): NameObject("/Helvetica"),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
p0_lines = ["Introduction to Networking", "CCNA Official Guide Header"] + [f"Introductory networking line {i} with several words for threshold." for i in range(12)]
|
||||
p1_lines = ["Chapter 1: Ethernet Switching", "Router# show ip interface brief", "GigabitEthernet0/0 10.0.0.1 up up", "Router# configure terminal", "Switch(config)# vlan 10"] + [f"Ethernet switching explanation line {i} with concepts." for i in range(12)]
|
||||
p2_lines = ["Chapter 2: IPv4 Routing", "Router# show ip route", "C 10.0.0.0/24 is directly connected"] + [f"IPv4 routing explanation line {i} with concepts." for i in range(12)]
|
||||
|
||||
add_text_page(p0_lines)
|
||||
add_text_page(p1_lines)
|
||||
add_text_page(p2_lines)
|
||||
|
||||
if with_outline:
|
||||
writer.add_outline_item("Introduction", 0)
|
||||
writer.add_outline_item("Chapter 1: Ethernet Switching", 1)
|
||||
writer.add_outline_item("Chapter 2: IPv4 Routing", 2)
|
||||
|
||||
buf = io.BytesIO()
|
||||
writer.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_pdf_reader_unit():
|
||||
import io
|
||||
import pypdf
|
||||
from app.reader import parse_pdf, format_cli_blocks, clean_page_lines
|
||||
|
||||
# 1. Test CLI block detection
|
||||
sample_cli = "Check interfaces:\nRouter# show ip interface brief\nGigabitEthernet0/0 10.0.0.1 up up\nRouter# exit\nAll done."
|
||||
formatted = format_cli_blocks(sample_cli)
|
||||
assert "```text" in formatted
|
||||
assert "Router# show ip interface brief" in formatted
|
||||
assert "```" in formatted
|
||||
|
||||
# 2. Test outline-based PDF parsing
|
||||
pdf_bytes = make_mock_pdf("CCNA Guide", "Cisco Press", with_outline=True)
|
||||
title, author, chapters = parse_pdf(pdf_bytes, filename="ccna_guide.pdf")
|
||||
assert title == "ccna_guide"
|
||||
assert len(chapters) == 3
|
||||
assert chapters[0]["title"] == "Introduction"
|
||||
assert chapters[1]["title"] == "Chapter 1: Ethernet Switching"
|
||||
assert chapters[2]["title"] == "Chapter 2: IPv4 Routing"
|
||||
assert "```text" in chapters[1]["content"]
|
||||
|
||||
# 3. Test fallback when no outline (heading regex detection)
|
||||
pdf_no_outline = make_mock_pdf("Manual", "Admin", with_outline=False)
|
||||
t2, a2, chs2 = parse_pdf(pdf_no_outline, filename="network_manual.pdf")
|
||||
assert len(chs2) >= 2
|
||||
assert any("Chapter 1" in ch["title"] for ch in chs2)
|
||||
|
||||
# 4. Test Scanned PDF Guard (< 100 words raises ValueError)
|
||||
empty_writer = pypdf.PdfWriter()
|
||||
empty_writer.add_blank_page(width=100, height=100)
|
||||
buf_scanned = io.BytesIO()
|
||||
empty_writer.write(buf_scanned)
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="текстового слоя"):
|
||||
parse_pdf(buf_scanned.getvalue(), filename="scan.pdf")
|
||||
|
||||
|
||||
def test_books_pdf_multipart_upload():
|
||||
# 1. Successful PDF upload
|
||||
pdf_data = make_mock_pdf()
|
||||
files = {"file": ("ccna_cert_guide.pdf", pdf_data, "application/pdf")}
|
||||
resp = client.post("/api/books", files=files)
|
||||
assert resp.status_code == 200
|
||||
book = resp.json()
|
||||
assert book["title"] == "ccna_cert_guide"
|
||||
assert book["source_type"] == "pdf"
|
||||
assert len(book["chapters"]) == 3
|
||||
assert book["total_words"] > 100
|
||||
book_id = book["id"]
|
||||
|
||||
# Verify retrieval
|
||||
get_resp = client.get(f"/api/books/{book_id}")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["source_type"] == "pdf"
|
||||
|
||||
# 2. Upload scanned PDF without OCR text returns 400 with informative detail
|
||||
import io, pypdf
|
||||
empty_writer = pypdf.PdfWriter()
|
||||
empty_writer.add_blank_page(width=100, height=100)
|
||||
buf = io.BytesIO()
|
||||
empty_writer.write(buf)
|
||||
|
||||
scan_files = {"file": ("scanned_cisco.pdf", buf.getvalue(), "application/pdf")}
|
||||
err_resp = client.post("/api/books", files=scan_files)
|
||||
assert err_resp.status_code == 400
|
||||
assert "текстового слоя" in err_resp.json()["detail"]
|
||||
|
||||
# Cleanup
|
||||
client.delete(f"/api/books/{book_id}")
|
||||
Reference in new issue
Block a user