auto trim TOC, rewrite test to use actual MPC calls

This commit is contained in:
Willem Melching committed 2026-03-15 13:27:04 +01:00
1 parent 6156a76a91
commit aaa9b83949
8 files changed
+488 -171

No files matched your search

+91
View File
@@ -161,11 +161,102 @@ def create_search_targets():
doc.close() doc.close()
def create_large_toc():
"""PDF with a very large multi-level TOC to test auto-trimming."""
doc = fitz.open()
toc = []
page_num = 0
for part in range(1, 6):
page_num += 1
page = doc.new_page(width=595, height=842)
page.insert_text((72, 80), f"Part {part}", fontsize=18)
toc.append([1, f"Part {part}", page_num])
for chapter in range(1, 9):
page_num += 1
page = doc.new_page(width=595, height=842)
title = f"Chapter {part}.{chapter}"
page.insert_text((72, 80), title, fontsize=14)
toc.append([2, title, page_num])
for section in range(1, 5):
page_num += 1
page = doc.new_page(width=595, height=842)
title = f"Section {part}.{chapter}.{section}"
page.insert_text((72, 80), title, fontsize=12)
toc.append([3, title, page_num])
doc.set_toc(toc)
doc.save(ASSETS / "large_toc.pdf")
doc.close()
def create_mega_toc():
"""PDF with 400+ pages and 4-level deep TOC for comprehensive auto-trim testing.
Structure:
2 volumes (L1) × 8 chapters (L2) × 8 sections (L3) × 2 subsections (L4)
Entry counts:
Level 1: 2
Level 2: 16 (cumulative ≤L2: 18)
Level 3: 128 (cumulative ≤L3: 146)
Level 4: 256 (cumulative ≤L4: 402)
Full-TOC auto-trim (threshold 100):
≤L2 = 18 fits → best_level=2, total=18, auto_trimmed_to_level=2
Volume 1 subtree (parent="Volume 1"):
8 chapters + 64 sections + 128 subsections = 200 children
≤L2 = 8, ≤L3 = 72 fits → best_level=3, total=72, auto_trimmed_to_level=2 (relative)
Chapter 1.1 subtree (parent="Chapter 1.1"):
8 sections + 16 subsections = 24 children (< 100, no auto-trim)
"""
doc = fitz.open()
toc = []
page_num = 0
for vol in range(1, 3):
page_num += 1
page = doc.new_page(width=595, height=842)
page.insert_text((72, 80), f"Volume {vol}", fontsize=18)
toc.append([1, f"Volume {vol}", page_num])
for ch in range(1, 9):
page_num += 1
page = doc.new_page(width=595, height=842)
title = f"Chapter {vol}.{ch}"
page.insert_text((72, 80), title, fontsize=14)
toc.append([2, title, page_num])
for sec in range(1, 9):
page_num += 1
page = doc.new_page(width=595, height=842)
title = f"Section {vol}.{ch}.{sec}"
page.insert_text((72, 80), title, fontsize=12)
toc.append([3, title, page_num])
for sub in range(1, 3):
page_num += 1
page = doc.new_page(width=595, height=842)
title = f"Subsection {vol}.{ch}.{sec}.{sub}"
page.insert_text((72, 80), title, fontsize=11)
toc.append([4, title, page_num])
doc.set_toc(toc)
doc.save(ASSETS / "mega_toc.pdf")
doc.close()
if __name__ == "__main__": if __name__ == "__main__":
create_basic() create_basic()
create_with_diagrams() create_with_diagrams()
create_nested_toc() create_nested_toc()
create_search_targets() create_search_targets()
create_large_toc()
create_mega_toc()
print(f"Generated test PDFs in {ASSETS}") print(f"Generated test PDFs in {ASSETS}")
for f in sorted(ASSETS.glob("*.pdf")): for f in sorted(ASSETS.glob("*.pdf")):
print(f" {f.name}") print(f" {f.name}")
+94 -13
View File
@@ -13,14 +13,15 @@ mcp = FastMCP(
) )
HEADER_FOOTER_MARGIN_PTS = 50 HEADER_FOOTER_MARGIN_PTS = 50
TOC_AUTO_TRIM_THRESHOLD = 100
@mcp.tool() @mcp.tool()
def get_pdf_info(filename: str) -> dict: def get_pdf_info(filename: str) -> dict:
"""Get metadata and basic info about a PDF file. """Get metadata and basic info about a PDF file.
Returns page count, title, author, subject, creator, producer, Args:
creation/modification dates, and encryption info. filename: Path to a PDF file.
""" """
doc = fitz.open(filename) doc = fitz.open(filename)
info = { info = {
@@ -34,16 +35,94 @@ def get_pdf_info(filename: str) -> dict:
@mcp.tool() @mcp.tool()
def get_table_of_contents(filename: str) -> list[dict]: def get_table_of_contents(
filename: str,
parent: str | None = None,
max_level: int | None = None,
) -> dict:
"""Get the table of contents (bookmarks/outline) from a PDF. """Get the table of contents (bookmarks/outline) from a PDF.
Returns a list of entries with level, title, and page number. When max_level is not specified and the TOC is very large, the depth is automatically
These correspond to the sections shown in a PDF reader's sidebar. reduced to keep the response manageable. The response will include an
``auto_trimmed_to_level`` field and a hint when this happens.
Args:
filename: Path to a PDF file.
parent: Return only children of the entry whose title contains this string
(case-insensitive). For example, pass a chapter title to get its sections.
max_level: Only include entries up to this depth (1=chapters only, 2=sections, etc.).
When used with parent, levels are relative: 1 means direct children only,
2 means children and grandchildren, etc.
""" """
doc = fitz.open(filename) doc = fitz.open(filename)
toc = doc.get_toc() toc = doc.get_toc()
doc.close() doc.close()
return [{"level": level, "title": title, "page": page} for level, title, page in toc]
# Track whether max_level was explicitly requested
explicit_max_level = max_level is not None
base_level = 0
if parent is not None:
parent_lower = parent.lower()
# Find the first entry whose title matches
parent_idx = None
parent_level = None
for i, (level, title, _page) in enumerate(toc):
if parent_lower in title.lower():
parent_idx = i
parent_level = level
break
if parent_idx is None:
return {
"error": f"No TOC entry matching '{parent}'",
"total_entries": 0,
"entries": [],
}
# Collect all subsequent entries until we hit an entry at the same or higher level
children = []
for level, title, page in toc[parent_idx + 1 :]:
if level <= parent_level:
break
children.append((level, title, page))
toc = children
base_level = parent_level
# Make max_level relative to parent
if max_level is not None:
abs_max_level = parent_level + max_level
toc = [entry for entry in toc if entry[0] <= abs_max_level]
elif max_level is not None:
toc = [entry for entry in toc if entry[0] <= max_level]
# Auto-trim depth when max_level was not explicitly requested and the TOC is large
applied_max_level = None
untrimmed_total = len(toc)
if not explicit_max_level and len(toc) > TOC_AUTO_TRIM_THRESHOLD:
levels_present = sorted(set(entry[0] for entry in toc))
# Find the deepest level that keeps entries at or below the threshold.
# If even the shallowest level exceeds it, use that (can't go shallower).
best_level = levels_present[0]
for try_level in levels_present:
count = sum(1 for entry in toc if entry[0] <= try_level)
if count <= TOC_AUTO_TRIM_THRESHOLD:
best_level = try_level
else:
break
toc = [entry for entry in toc if entry[0] <= best_level]
applied_max_level = best_level - base_level
entries = [
{"level": level, "title": title, "page": page} for level, title, page in toc
]
result = {"total_entries": len(entries), "entries": entries}
if applied_max_level is not None:
result["auto_trimmed_to_level"] = applied_max_level
result["hint"] = (
f"TOC has {untrimmed_total} entries; trimmed to level {applied_max_level} "
f"({len(entries)} entries). Use max_level to request a specific depth, "
"or use parent to narrow to a specific section."
)
return result
@mcp.tool() @mcp.tool()
@@ -57,7 +136,7 @@ def get_page_text(
"""Extract text content from one or more PDF pages. """Extract text content from one or more PDF pages.
Args: Args:
filename: Path to the PDF file. filename: Path to a PDF file.
start_page: First page number (1-indexed, inclusive). start_page: First page number (1-indexed, inclusive).
end_page: Last page number (1-indexed, inclusive). Defaults to start_page. end_page: Last page number (1-indexed, inclusive). Defaults to start_page.
format: Output format. "json" returns structured page data with block/line/span format: Output format. "json" returns structured page data with block/line/span
@@ -132,7 +211,7 @@ def get_page_image(
"""Render a single PDF page as a PNG image. """Render a single PDF page as a PNG image.
Args: Args:
filename: Path to the PDF file. filename: Path to a PDF file.
page: Page number (1-indexed). page: Page number (1-indexed).
dpi: Image resolution. Default 150 (good balance of readability and size). dpi: Image resolution. Default 150 (good balance of readability and size).
output: "base64" returns the image inline as MCP image content. output: "base64" returns the image inline as MCP image content.
@@ -166,7 +245,7 @@ def search_text(
Returns a list of hits with page number and surrounding context. Returns a list of hits with page number and surrounding context.
Args: Args:
filename: Path to the PDF file. filename: Path to a PDF file.
query: Text to search for. query: Text to search for.
context_chars: Characters of context to include around each hit. Default 100. context_chars: Characters of context to include around each hit. Default 100.
""" """
@@ -185,10 +264,12 @@ def search_text(
break break
ctx_start = max(0, idx - context_chars) ctx_start = max(0, idx - context_chars)
ctx_end = min(len(text), idx + len(query) + context_chars) ctx_end = min(len(text), idx + len(query) + context_chars)
hits.append({ hits.append(
"page": page_idx + 1, {
"context": text[ctx_start:ctx_end], "page": page_idx + 1,
}) "context": text[ctx_start:ctx_end],
}
)
pos = idx + 1 pos = idx + 1
doc.close() doc.close()
+24 -1
View File
@@ -1,23 +1,36 @@
"""Shared fixtures for pdf-mcp tests.""" """Shared fixtures for pdf-mcp tests."""
import asyncio
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from pdf_mcp.server import mcp
ASSETS = Path(__file__).parent.parent / "assets" ASSETS = Path(__file__).parent.parent / "assets"
def pytest_configure(config): def pytest_configure(config):
"""Generate test PDFs before the test session if they don't exist.""" """Generate test PDFs before the test session if they don't exist."""
expected = ["basic.pdf", "diagrams.pdf", "nested_toc.pdf", "search_targets.pdf"] expected = ["basic.pdf", "diagrams.pdf", "nested_toc.pdf", "search_targets.pdf", "large_toc.pdf", "mega_toc.pdf"]
if not all((ASSETS / f).exists() for f in expected): if not all((ASSETS / f).exists() for f in expected):
subprocess.check_call( subprocess.check_call(
[sys.executable, str(ASSETS / "generate.py")], [sys.executable, str(ASSETS / "generate.py")],
) )
@pytest.fixture
def call_tool():
"""Call an MCP tool by name, going through the real FastMCP call path."""
def _call(tool_name: str, **kwargs):
tools = mcp._tool_manager.list_tools()
tool = next(t for t in tools if t.name == tool_name)
return asyncio.run(tool.run(kwargs))
return _call
@pytest.fixture @pytest.fixture
def basic_pdf() -> str: def basic_pdf() -> str:
return str(ASSETS / "basic.pdf") return str(ASSETS / "basic.pdf")
@@ -36,3 +49,13 @@ def nested_toc_pdf() -> str:
@pytest.fixture @pytest.fixture
def search_pdf() -> str: def search_pdf() -> str:
return str(ASSETS / "search_targets.pdf") return str(ASSETS / "search_targets.pdf")
@pytest.fixture
def large_toc_pdf() -> str:
return str(ASSETS / "large_toc.pdf")
@pytest.fixture
def mega_toc_pdf() -> str:
return str(ASSETS / "mega_toc.pdf")
+27 -32
View File
@@ -3,75 +3,70 @@ from pathlib import Path
from mcp.server.fastmcp import Image from mcp.server.fastmcp import Image
from pdf_mcp.server import get_page_image
class TestGetPageImageBase64: class TestGetPageImageBase64:
def test_returns_image_object(self, basic_pdf): def test_returns_image_object(self, basic_pdf, call_tool):
result = get_page_image(basic_pdf, page=1) result = call_tool("get_page_image", filename=basic_pdf, page=1)
assert isinstance(result, Image) assert isinstance(result, Image)
def test_image_has_data(self, basic_pdf): def test_image_has_data(self, basic_pdf, call_tool):
result = get_page_image(basic_pdf, page=1) result = call_tool("get_page_image", filename=basic_pdf, page=1)
assert len(result.data) > 0 assert len(result.data) > 0
def test_png_signature(self, basic_pdf): def test_png_signature(self, basic_pdf, call_tool):
result = get_page_image(basic_pdf, page=1) result = call_tool("get_page_image", filename=basic_pdf, page=1)
# PNG files start with the 8-byte signature
assert result.data[:4] == b"\x89PNG" assert result.data[:4] == b"\x89PNG"
def test_different_pages(self, basic_pdf): def test_different_pages(self, basic_pdf, call_tool):
img1 = get_page_image(basic_pdf, page=1) img1 = call_tool("get_page_image", filename=basic_pdf, page=1)
img2 = get_page_image(basic_pdf, page=2) img2 = call_tool("get_page_image", filename=basic_pdf, page=2)
assert img1.data != img2.data assert img1.data != img2.data
class TestGetPageImageFile: class TestGetPageImageFile:
def test_returns_string_with_path(self, basic_pdf): def test_returns_string_with_path(self, basic_pdf, call_tool):
result = get_page_image(basic_pdf, page=1, output="file") result = call_tool("get_page_image", filename=basic_pdf, page=1, output="file")
assert isinstance(result, str) assert isinstance(result, str)
assert "Image saved to" in result assert "Image saved to" in result
def test_file_exists(self, basic_pdf): def test_file_exists(self, basic_pdf, call_tool):
result = get_page_image(basic_pdf, page=1, output="file") result = call_tool("get_page_image", filename=basic_pdf, page=1, output="file")
# Extract path from "Image saved to /path/to/file.png (WxHpx)"
path = result.split("Image saved to ")[1].split(" (")[0] path = result.split("Image saved to ")[1].split(" (")[0]
assert Path(path).exists() assert Path(path).exists()
os.unlink(path) os.unlink(path)
def test_file_is_valid_png(self, basic_pdf): def test_file_is_valid_png(self, basic_pdf, call_tool):
result = get_page_image(basic_pdf, page=1, output="file") result = call_tool("get_page_image", filename=basic_pdf, page=1, output="file")
path = result.split("Image saved to ")[1].split(" (")[0] path = result.split("Image saved to ")[1].split(" (")[0]
with open(path, "rb") as f: with open(path, "rb") as f:
assert f.read(4) == b"\x89PNG" assert f.read(4) == b"\x89PNG"
os.unlink(path) os.unlink(path)
def test_file_includes_dimensions(self, basic_pdf): def test_file_includes_dimensions(self, basic_pdf, call_tool):
result = get_page_image(basic_pdf, page=1, output="file") result = call_tool("get_page_image", filename=basic_pdf, page=1, output="file")
# Should contain something like "(1240x1754px)"
assert "px)" in result assert "px)" in result
class TestGetPageImageDpi: class TestGetPageImageDpi:
def test_higher_dpi_larger_file(self, basic_pdf): def test_higher_dpi_larger_file(self, basic_pdf, call_tool):
low = get_page_image(basic_pdf, page=1, dpi=72) low = call_tool("get_page_image", filename=basic_pdf, page=1, dpi=72)
high = get_page_image(basic_pdf, page=1, dpi=300) high = call_tool("get_page_image", filename=basic_pdf, page=1, dpi=300)
assert len(high.data) > len(low.data) assert len(high.data) > len(low.data)
def test_default_dpi(self, basic_pdf): def test_default_dpi(self, basic_pdf, call_tool):
default = get_page_image(basic_pdf, page=1) default = call_tool("get_page_image", filename=basic_pdf, page=1)
explicit_150 = get_page_image(basic_pdf, page=1, dpi=150) explicit_150 = call_tool("get_page_image", filename=basic_pdf, page=1, dpi=150)
assert len(default.data) == len(explicit_150.data) assert len(default.data) == len(explicit_150.data)
class TestGetPageImageDiagrams: class TestGetPageImageDiagrams:
def test_diagrams_renders(self, diagrams_pdf): def test_diagrams_renders(self, diagrams_pdf, call_tool):
result = get_page_image(diagrams_pdf, page=1) result = call_tool("get_page_image", filename=diagrams_pdf, page=1)
assert isinstance(result, Image) assert isinstance(result, Image)
assert len(result.data) > 0 assert len(result.data) > 0
def test_diagrams_file_output(self, diagrams_pdf): def test_diagrams_file_output(self, diagrams_pdf, call_tool):
result = get_page_image(diagrams_pdf, page=1, output="file") result = call_tool("get_page_image", filename=diagrams_pdf, page=1, output="file")
path = result.split("Image saved to ")[1].split(" (")[0] path = result.split("Image saved to ")[1].split(" (")[0]
assert Path(path).exists() assert Path(path).exists()
assert Path(path).stat().st_size > 0 assert Path(path).stat().st_size > 0
+43 -47
View File
@@ -1,120 +1,116 @@
from pdf_mcp.server import get_page_text
class TestGetPageTextJson: class TestGetPageTextJson:
def test_default_format_is_json(self, basic_pdf): def test_default_format_is_json(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1) result = call_tool("get_page_text", filename=basic_pdf, start_page=1)
assert isinstance(result, list) assert isinstance(result, list)
assert len(result) == 1 assert len(result) == 1
assert isinstance(result[0], dict) assert isinstance(result[0], dict)
def test_json_has_page_number(self, basic_pdf): def test_json_has_page_number(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=2) result = call_tool("get_page_text", filename=basic_pdf, start_page=2)
assert result[0]["page_number"] == 2 assert result[0]["page_number"] == 2
def test_json_has_blocks(self, basic_pdf): def test_json_has_blocks(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1) result = call_tool("get_page_text", filename=basic_pdf, start_page=1)
assert "blocks" in result[0] assert "blocks" in result[0]
assert len(result[0]["blocks"]) > 0 assert len(result[0]["blocks"]) > 0
def test_json_page_range(self, basic_pdf): def test_json_page_range(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, end_page=3, format="json") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, end_page=3, format="json")
assert len(result) == 3 assert len(result) == 3
assert result[0]["page_number"] == 1 assert result[0]["page_number"] == 1
assert result[2]["page_number"] == 3 assert result[2]["page_number"] == 3
class TestGetPageTextPlain: class TestGetPageTextPlain:
def test_text_format_returns_string(self, basic_pdf): def test_text_format_returns_string(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="text") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="text")
assert isinstance(result, str) assert isinstance(result, str)
def test_text_contains_content(self, basic_pdf): def test_text_contains_content(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="text") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="text")
assert "Chapter 1" in result assert "Chapter 1" in result
assert "introductory material" in result assert "introductory material" in result
def test_text_page_range_has_breaks(self, basic_pdf): def test_text_page_range_has_breaks(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, end_page=2, format="text") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, end_page=2, format="text")
assert "--- Page Break ---" in result assert "--- Page Break ---" in result
assert "Chapter 1" in result assert "Chapter 1" in result
assert "Chapter 2" in result assert "Chapter 2" in result
def test_text_single_page_no_break(self, basic_pdf): def test_text_single_page_no_break(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="text") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="text")
assert "--- Page Break ---" not in result assert "--- Page Break ---" not in result
class TestGetPageTextMarkdown: class TestGetPageTextMarkdown:
def test_markdown_returns_string(self, basic_pdf): def test_markdown_returns_string(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="markdown") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="markdown")
assert isinstance(result, str) assert isinstance(result, str)
def test_markdown_contains_content(self, basic_pdf): def test_markdown_contains_content(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="markdown") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="markdown")
assert "Chapter 1" in result assert "Chapter 1" in result
def test_markdown_page_range(self, basic_pdf): def test_markdown_page_range(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, end_page=3, format="markdown") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, end_page=3, format="markdown")
assert "Chapter 1" in result assert "Chapter 1" in result
assert "Chapter 3" in result assert "Chapter 3" in result
class TestGetPageTextHtml: class TestGetPageTextHtml:
def test_html_returns_string(self, basic_pdf): def test_html_returns_string(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="html") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="html")
assert isinstance(result, str) assert isinstance(result, str)
def test_html_has_tags(self, basic_pdf): def test_html_has_tags(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="html") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="html")
assert "<div" in result assert "<div" in result
assert "<span" in result assert "<span" in result
def test_html_contains_content(self, basic_pdf): def test_html_contains_content(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="html") result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="html")
assert "Chapter 1" in result assert "Chapter 1" in result
class TestGetPageTextHeaderFooter: class TestGetPageTextHeaderFooter:
def test_with_headers_footers(self, basic_pdf): def test_with_headers_footers(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=1, format="text", include_headers_footers=True) result = call_tool("get_page_text", filename=basic_pdf, start_page=1, format="text", include_headers_footers=True)
assert "Header" in result assert "Header" in result
assert "Page 1 of 3" in result assert "Page 1 of 3" in result
def test_without_headers_footers(self, basic_pdf): def test_without_headers_footers(self, basic_pdf, call_tool):
result = get_page_text( result = call_tool(
basic_pdf, start_page=1, format="text", include_headers_footers=False "get_page_text", filename=basic_pdf, start_page=1, format="text", include_headers_footers=False
) )
# Header text is at y=30, footer at y=820 — both within the 50pt margin crop
assert "Header" not in result assert "Header" not in result
assert "Page 1 of 3" not in result assert "Page 1 of 3" not in result
def test_body_preserved_without_headers_footers(self, basic_pdf): def test_body_preserved_without_headers_footers(self, basic_pdf, call_tool):
result = get_page_text( result = call_tool(
basic_pdf, start_page=1, format="text", include_headers_footers=False "get_page_text", filename=basic_pdf, start_page=1, format="text", include_headers_footers=False
) )
assert "Chapter 1" in result assert "Chapter 1" in result
assert "introductory material" in result assert "introductory material" in result
def test_markdown_without_headers_footers(self, basic_pdf): def test_markdown_without_headers_footers(self, basic_pdf, call_tool):
result = get_page_text( result = call_tool(
basic_pdf, start_page=1, format="markdown", include_headers_footers=False "get_page_text", filename=basic_pdf, start_page=1, format="markdown", include_headers_footers=False
) )
assert "Chapter 1" in result assert "Chapter 1" in result
assert "Header" not in result assert "Header" not in result
class TestGetPageTextEndPageDefault: class TestGetPageTextEndPageDefault:
def test_end_page_defaults_to_start(self, basic_pdf): def test_end_page_defaults_to_start(self, basic_pdf, call_tool):
result = get_page_text(basic_pdf, start_page=2, format="text") result = call_tool("get_page_text", filename=basic_pdf, start_page=2, format="text")
assert "Chapter 2" in result assert "Chapter 2" in result
assert "Chapter 1" not in result assert "Chapter 1" not in result
assert "Chapter 3" not in result assert "Chapter 3" not in result
class TestGetPageTextDiagrams: class TestGetPageTextDiagrams:
def test_diagrams_text_extraction(self, diagrams_pdf): def test_diagrams_text_extraction(self, diagrams_pdf, call_tool):
result = get_page_text(diagrams_pdf, start_page=1, format="text") result = call_tool("get_page_text", filename=diagrams_pdf, start_page=1, format="text")
assert "Component A" in result assert "Component A" in result
assert "Component B" in result assert "Component B" in result
assert "Pin Configuration" in result assert "Pin Configuration" in result
+10 -13
View File
@@ -1,9 +1,6 @@
from pdf_mcp.server import get_pdf_info
class TestGetPdfInfo: class TestGetPdfInfo:
def test_basic_metadata(self, basic_pdf): def test_basic_metadata(self, basic_pdf, call_tool):
info = get_pdf_info(basic_pdf) info = call_tool("get_pdf_info", filename=basic_pdf)
assert info["page_count"] == 3 assert info["page_count"] == 3
assert info["metadata"]["title"] == "Test Document" assert info["metadata"]["title"] == "Test Document"
assert info["metadata"]["author"] == "Test Author" assert info["metadata"]["author"] == "Test Author"
@@ -12,18 +9,18 @@ class TestGetPdfInfo:
assert info["metadata"]["creator"] == "generate.py" assert info["metadata"]["creator"] == "generate.py"
assert info["is_encrypted"] is False assert info["is_encrypted"] is False
def test_filename_returned(self, basic_pdf): def test_filename_returned(self, basic_pdf, call_tool):
info = get_pdf_info(basic_pdf) info = call_tool("get_pdf_info", filename=basic_pdf)
assert info["filename"] == basic_pdf assert info["filename"] == basic_pdf
def test_single_page_pdf(self, diagrams_pdf): def test_single_page_pdf(self, diagrams_pdf, call_tool):
info = get_pdf_info(diagrams_pdf) info = call_tool("get_pdf_info", filename=diagrams_pdf)
assert info["page_count"] == 1 assert info["page_count"] == 1
def test_multi_page_pdf(self, nested_toc_pdf): def test_multi_page_pdf(self, nested_toc_pdf, call_tool):
info = get_pdf_info(nested_toc_pdf) info = call_tool("get_pdf_info", filename=nested_toc_pdf)
assert info["page_count"] == 9 assert info["page_count"] == 9
def test_search_targets_page_count(self, search_pdf): def test_search_targets_page_count(self, search_pdf, call_tool):
info = get_pdf_info(search_pdf) info = call_tool("get_pdf_info", filename=search_pdf)
assert info["page_count"] == 3 assert info["page_count"] == 3
+165 -27
View File
@@ -1,39 +1,177 @@
from pdf_mcp.server import get_table_of_contents
class TestGetTableOfContents: class TestGetTableOfContents:
def test_basic_toc(self, basic_pdf): def test_basic_toc(self, basic_pdf, call_tool):
toc = get_table_of_contents(basic_pdf) result = call_tool("get_table_of_contents", filename=basic_pdf)
assert len(toc) == 3 assert result["total_entries"] == 3
assert toc[0] == {"level": 1, "title": "Chapter 1", "page": 1} entries = result["entries"]
assert toc[1] == {"level": 1, "title": "Chapter 2", "page": 2} assert entries[0] == {"level": 1, "title": "Chapter 1", "page": 1}
assert toc[2] == {"level": 1, "title": "Chapter 3", "page": 3} assert entries[1] == {"level": 1, "title": "Chapter 2", "page": 2}
assert entries[2] == {"level": 1, "title": "Chapter 3", "page": 3}
def test_nested_toc_entries(self, nested_toc_pdf): def test_nested_toc_entries(self, nested_toc_pdf, call_tool):
toc = get_table_of_contents(nested_toc_pdf) result = call_tool("get_table_of_contents", filename=nested_toc_pdf)
assert len(toc) == 9 assert result["total_entries"] == 9
def test_nested_toc_levels(self, nested_toc_pdf): def test_nested_toc_levels(self, nested_toc_pdf, call_tool):
toc = get_table_of_contents(nested_toc_pdf) result = call_tool("get_table_of_contents", filename=nested_toc_pdf)
levels = [e["level"] for e in toc] levels = [e["level"] for e in result["entries"]]
assert levels == [1, 1, 2, 2, 3, 3, 1, 2, 2] assert levels == [1, 1, 2, 2, 3, 3, 1, 2, 2]
def test_nested_toc_titles(self, nested_toc_pdf): def test_nested_toc_titles(self, nested_toc_pdf, call_tool):
toc = get_table_of_contents(nested_toc_pdf) result = call_tool("get_table_of_contents", filename=nested_toc_pdf)
titles = [e["title"] for e in toc] titles = [e["title"] for e in result["entries"]]
assert "Introduction" in titles assert "Introduction" in titles
assert "Database Layer" in titles assert "Database Layer" in titles
assert "Kubernetes" in titles assert "Kubernetes" in titles
def test_nested_toc_page_numbers(self, nested_toc_pdf): def test_nested_toc_page_numbers(self, nested_toc_pdf, call_tool):
toc = get_table_of_contents(nested_toc_pdf) result = call_tool("get_table_of_contents", filename=nested_toc_pdf)
pages = [e["page"] for e in toc] pages = [e["page"] for e in result["entries"]]
assert pages == list(range(1, 10)) assert pages == list(range(1, 10))
def test_no_toc(self, search_pdf): def test_no_toc(self, search_pdf, call_tool):
toc = get_table_of_contents(search_pdf) result = call_tool("get_table_of_contents", filename=search_pdf)
assert toc == [] assert result["total_entries"] == 0
assert result["entries"] == []
def test_no_toc_diagrams(self, diagrams_pdf): def test_no_toc_diagrams(self, diagrams_pdf, call_tool):
toc = get_table_of_contents(diagrams_pdf) result = call_tool("get_table_of_contents", filename=diagrams_pdf)
assert toc == [] assert result["total_entries"] == 0
assert result["entries"] == []
def test_no_auto_trim_small_toc(self, nested_toc_pdf, call_tool):
result = call_tool("get_table_of_contents", filename=nested_toc_pdf)
assert "auto_trimmed_to_level" not in result
assert "hint" not in result
class TestAutoTrim:
"""Test auto-trimming with 402-entry, 4-level deep mega TOC.
mega_toc.pdf structure:
2 volumes (L1) × 8 chapters (L2) × 8 sections (L3) × 2 subsections (L4)
Cumulative: ≤L1=2, ≤L2=18, ≤L3=146, ≤L4=402
Threshold: 100
"""
def test_full_toc_auto_trims_to_level_2(self, mega_toc_pdf, call_tool):
"""Full TOC (402 entries) auto-trims to level 2 (18 entries ≤ 100)."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf)
assert result["auto_trimmed_to_level"] == 2
assert result["total_entries"] == 18
assert all(e["level"] <= 2 for e in result["entries"])
def test_auto_trim_hint_present(self, mega_toc_pdf, call_tool):
result = call_tool("get_table_of_contents", filename=mega_toc_pdf)
assert "hint" in result
assert "402" in result["hint"]
assert "18" in result["hint"]
assert "level 2" in result["hint"]
def test_auto_trim_entries_are_volumes_and_chapters(self, mega_toc_pdf, call_tool):
"""Trimmed entries should be exactly the 2 volumes + 16 chapters."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf)
entries = result["entries"]
assert len(entries) == 18
volumes = [e for e in entries if e["level"] == 1]
chapters = [e for e in entries if e["level"] == 2]
assert len(volumes) == 2
assert len(chapters) == 16
def test_explicit_max_level_skips_auto_trim(self, mega_toc_pdf, call_tool):
"""When max_level is explicitly set, auto-trim must not activate."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, max_level=4)
assert "auto_trimmed_to_level" not in result
assert "hint" not in result
assert result["total_entries"] == 402
def test_explicit_max_level_3(self, mega_toc_pdf, call_tool):
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, max_level=3)
assert "auto_trimmed_to_level" not in result
assert result["total_entries"] == 146
def test_explicit_max_level_1(self, mega_toc_pdf, call_tool):
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, max_level=1)
assert result["total_entries"] == 2
assert all(e["level"] == 1 for e in result["entries"])
class TestAutoTrimWithParent:
"""Test auto-trim when filtering by parent.
Volume 1 subtree: 8 ch + 64 sec + 128 subsec = 200 children
≤L2=8, ≤L3=72, ≤L4=200 → auto-trim to L3 (72 entries)
applied_max_level = 3 - 1 = 2 (relative to Volume 1)
Chapter 1.1 subtree: 8 sec + 16 subsec = 24 children (< 100, no trim)
"""
def test_parent_subtree_auto_trims(self, mega_toc_pdf, call_tool):
"""Volume 1's 200-entry subtree should auto-trim to 72 entries."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Volume 1")
assert result["auto_trimmed_to_level"] == 2 # relative to parent
assert result["total_entries"] == 72
assert "hint" in result
def test_parent_subtree_trimmed_levels(self, mega_toc_pdf, call_tool):
"""After trimming, only chapters (L2) and sections (L3) remain."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Volume 1")
levels = {e["level"] for e in result["entries"]}
assert levels == {2, 3}
def test_parent_small_subtree_no_trim(self, mega_toc_pdf, call_tool):
"""Chapter 1.1 subtree (24 entries) should not trigger auto-trim."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Chapter 1.1")
assert "auto_trimmed_to_level" not in result
assert "hint" not in result
assert result["total_entries"] == 24
def test_parent_small_subtree_all_levels(self, mega_toc_pdf, call_tool):
"""Small subtree should include all levels (sections + subsections)."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Chapter 1.1")
levels = {e["level"] for e in result["entries"]}
assert levels == {3, 4}
def test_parent_with_explicit_max_level_1(self, mega_toc_pdf, call_tool):
"""parent + max_level=1 → direct children only, no auto-trim."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Volume 1", max_level=1)
assert "auto_trimmed_to_level" not in result
assert result["total_entries"] == 8
assert all(e["level"] == 2 for e in result["entries"])
def test_parent_with_explicit_max_level_2(self, mega_toc_pdf, call_tool):
"""parent + max_level=2 → children + grandchildren, no auto-trim."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Volume 1", max_level=2)
assert "auto_trimmed_to_level" not in result
assert result["total_entries"] == 72
def test_parent_not_found(self, mega_toc_pdf, call_tool):
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Nonexistent Section")
assert result["error"] == "No TOC entry matching 'Nonexistent Section'"
assert result["total_entries"] == 0
def test_parent_case_insensitive(self, mega_toc_pdf, call_tool):
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="volume 1")
assert result["total_entries"] > 0
def test_parent_partial_match(self, mega_toc_pdf, call_tool):
"""Parent matching is substring-based."""
result = call_tool("get_table_of_contents", filename=mega_toc_pdf, parent="Chapter 2.")
assert result["total_entries"] > 0
class TestAutoTrimOldPdf:
"""Regression tests with large_toc.pdf (205 entries, 3 levels)."""
def test_auto_trim_large_toc(self, large_toc_pdf, call_tool):
"""large_toc.pdf: 205 entries, auto-trims to level 2 (45 entries)."""
result = call_tool("get_table_of_contents", filename=large_toc_pdf)
assert result["auto_trimmed_to_level"] == 2
assert result["total_entries"] == 45
assert "hint" in result
assert all(e["level"] <= 2 for e in result["entries"])
def test_no_auto_trim_when_explicit_max_level(self, large_toc_pdf, call_tool):
result = call_tool("get_table_of_contents", filename=large_toc_pdf, max_level=3)
assert "auto_trimmed_to_level" not in result
assert "hint" not in result
+34 -38
View File
@@ -1,88 +1,84 @@
from pdf_mcp.server import search_text
class TestSearchTextBasic: class TestSearchTextBasic:
def test_unique_marker_on_two_pages(self, search_pdf): def test_unique_marker_on_two_pages(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="UNIQUE_MARKER_ALPHA") hits = call_tool("search_text", filename=search_pdf, query="UNIQUE_MARKER_ALPHA")
pages = [h["page"] for h in hits] pages = [h["page"] for h in hits]
assert pages == [1, 3] assert pages == [1, 3]
def test_unique_marker_single_page(self, search_pdf): def test_unique_marker_single_page(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA") hits = call_tool("search_text", filename=search_pdf, query="UNIQUE_MARKER_BETA")
assert len(hits) == 1 assert len(hits) == 1
assert hits[0]["page"] == 2 assert hits[0]["page"] == 2
def test_no_results(self, search_pdf): def test_no_results(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="NONEXISTENT_TEXT_XYZ") hits = call_tool("search_text", filename=search_pdf, query="NONEXISTENT_TEXT_XYZ")
assert hits == [] assert hits == []
def test_multi_page_hits(self, search_pdf): def test_multi_page_hits(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="resistance") hits = call_tool("search_text", filename=search_pdf, query="resistance")
pages = [h["page"] for h in hits] pages = [h["page"] for h in hits]
assert 1 in pages assert 1 in pages
assert 2 in pages assert 2 in pages
class TestSearchTextCaseInsensitive: class TestSearchTextCaseInsensitive:
def test_lowercase_query(self, search_pdf): def test_lowercase_query(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="unique_marker_alpha") hits = call_tool("search_text", filename=search_pdf, query="unique_marker_alpha")
assert len(hits) == 2 assert len(hits) == 2
def test_mixed_case_query(self, search_pdf): def test_mixed_case_query(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="Unique_Marker_Alpha") hits = call_tool("search_text", filename=search_pdf, query="Unique_Marker_Alpha")
assert len(hits) == 2 assert len(hits) == 2
def test_case_insensitive_content(self, search_pdf): def test_case_insensitive_content(self, search_pdf, call_tool):
lower = search_text(search_pdf, query="resistance") lower = call_tool("search_text", filename=search_pdf, query="resistance")
upper = search_text(search_pdf, query="RESISTANCE") upper = call_tool("search_text", filename=search_pdf, query="RESISTANCE")
assert len(lower) == len(upper) assert len(lower) == len(upper)
class TestSearchTextContext: class TestSearchTextContext:
def test_context_contains_query(self, search_pdf): def test_context_contains_query(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA") hits = call_tool("search_text", filename=search_pdf, query="UNIQUE_MARKER_BETA")
assert "UNIQUE_MARKER_BETA" in hits[0]["context"] assert "UNIQUE_MARKER_BETA" in hits[0]["context"]
def test_context_has_surrounding_text(self, search_pdf): def test_context_has_surrounding_text(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA") hits = call_tool("search_text", filename=search_pdf, query="UNIQUE_MARKER_BETA")
# The context should include text around the match
assert len(hits[0]["context"]) > len("UNIQUE_MARKER_BETA") assert len(hits[0]["context"]) > len("UNIQUE_MARKER_BETA")
def test_custom_context_chars(self, search_pdf): def test_custom_context_chars(self, search_pdf, call_tool):
short = search_text(search_pdf, query="UNIQUE_MARKER_BETA", context_chars=10) short = call_tool("search_text", filename=search_pdf, query="UNIQUE_MARKER_BETA", context_chars=10)
long = search_text(search_pdf, query="UNIQUE_MARKER_BETA", context_chars=200) long = call_tool("search_text", filename=search_pdf, query="UNIQUE_MARKER_BETA", context_chars=200)
assert len(short[0]["context"]) <= len(long[0]["context"]) assert len(short[0]["context"]) <= len(long[0]["context"])
def test_zero_context(self, search_pdf): def test_zero_context(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="UNIQUE_MARKER_BETA", context_chars=0) hits = call_tool("search_text", filename=search_pdf, query="UNIQUE_MARKER_BETA", context_chars=0)
assert hits[0]["context"] == "UNIQUE_MARKER_BETA" assert hits[0]["context"] == "UNIQUE_MARKER_BETA"
class TestSearchTextHitStructure: class TestSearchTextHitStructure:
def test_hit_has_page(self, search_pdf): def test_hit_has_page(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="resistance") hits = call_tool("search_text", filename=search_pdf, query="resistance")
for hit in hits: for hit in hits:
assert "page" in hit assert "page" in hit
assert isinstance(hit["page"], int) assert isinstance(hit["page"], int)
def test_hit_has_context(self, search_pdf): def test_hit_has_context(self, search_pdf, call_tool):
hits = search_text(search_pdf, query="resistance") hits = call_tool("search_text", filename=search_pdf, query="resistance")
for hit in hits: for hit in hits:
assert "context" in hit assert "context" in hit
assert isinstance(hit["context"], str) assert isinstance(hit["context"], str)
class TestSearchTextOnOtherPdfs: class TestSearchTextOnOtherPdfs:
def test_search_basic_pdf(self, basic_pdf): def test_search_basic_pdf(self, basic_pdf, call_tool):
hits = search_text(basic_pdf, query="introductory") hits = call_tool("search_text", filename=basic_pdf, query="introductory")
assert len(hits) == 1 assert len(hits) == 1
assert hits[0]["page"] == 1 assert hits[0]["page"] == 1
def test_search_diagrams_pdf(self, diagrams_pdf): def test_search_diagrams_pdf(self, diagrams_pdf, call_tool):
hits = search_text(diagrams_pdf, query="Component A") hits = call_tool("search_text", filename=diagrams_pdf, query="Component A")
assert len(hits) == 1 assert len(hits) == 1
assert hits[0]["page"] == 1 assert hits[0]["page"] == 1
def test_search_nested_toc_pdf(self, nested_toc_pdf): def test_search_nested_toc_pdf(self, nested_toc_pdf, call_tool):
hits = search_text(nested_toc_pdf, query="Kubernetes") hits = call_tool("search_text", filename=nested_toc_pdf, query="Kubernetes")
assert len(hits) >= 1 assert len(hits) >= 1