feat(reader): add PDF reading support with outline chapters, CLI blocks, and noise filtering (task 7)

This commit is contained in:
2026-10-06 10:16:40 +03:00
parent c38b1dae0a
commit 6dcfd59f73
67 changed files with 49444 additions and 40 deletions
+120
View File
@@ -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}")