diff --git a/graphify/detect.py b/graphify/detect.py index 84dd5e1b6..1f38527f5 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -31,7 +31,7 @@ class FileType(str, Enum): DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} -OFFICE_EXTENSIONS = {'.docx', '.xlsx'} +OFFICE_EXTENSIONS = {'.docx', '.xlsx', '.pptx'} VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'} CORPUS_WARN_THRESHOLD = 50_000 # words - below this, warn "you may not need a graph" @@ -621,8 +621,30 @@ def _edge(src: str, tgt: str, relation: str) -> None: return {"nodes": nodes, "edges": edges} +def pptx_to_markdown(path: Path) -> str: + """Convert a .pptx file to markdown text using python-pptx.""" + if not _zip_within_caps(path): + return "" + try: + from pptx import Presentation + prs = Presentation(str(path)) + lines = [] + for i, slide in enumerate(prs.slides, start=1): + lines.append(f"## Slide {i}") + for shape in slide.shapes: + if shape.has_text_frame: + text = shape.text_frame.text.strip() + if text: + lines.append(text) + return "\n".join(lines) + except ImportError: + return "" + except Exception: + return "" + + def convert_office_file(path: Path, out_dir: Path) -> Path | None: - """Convert a .docx or .xlsx to a markdown sidecar in out_dir. + """Convert a .docx, .xlsx, or .pptx to a markdown sidecar in out_dir. Returns the path of the converted .md file, or None if conversion failed or the required library is not installed. @@ -632,6 +654,8 @@ def convert_office_file(path: Path, out_dir: Path) -> Path | None: text = docx_to_markdown(path) elif ext == ".xlsx": text = xlsx_to_markdown(path) + elif ext == ".pptx": + text = pptx_to_markdown(path) else: return None @@ -679,6 +703,8 @@ def count_words(path: Path) -> int: return len(docx_to_markdown(path).split()) if ext == ".xlsx": return len(xlsx_to_markdown(path).split()) + if ext == ".pptx": + return len(pptx_to_markdown(path).split()) with open(_os_path(path), encoding="utf-8", errors="ignore") as f: return len(f.read().split()) except Exception: diff --git a/pyproject.toml b/pyproject.toml index 50a7b63d8..fd5f613ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ pdf = ["pypdf>=6.12.0", "markdownify"] watch = ["watchdog"] svg = ["matplotlib", "numpy>=2.0; python_version >= '3.13'"] leiden = ["graspologic; python_version < '3.13'"] -office = ["python-docx", "openpyxl"] +office = ["python-docx", "openpyxl", "python-pptx"] google = ["openpyxl"] postgres = ["psycopg[binary]"] video = ["faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9"] @@ -80,7 +80,7 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] -all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] +all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "python-pptx", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/test_detect.py b/tests/test_detect.py index 7ff769768..a728df39d 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -1632,3 +1632,73 @@ def test_detect_surfaces_unreadable_dir_instead_of_silent_skip(tmp_path, capsys) assert any(f.endswith("a.py") for f in code) # rest of tree still enumerated assert len(res["walk_errors"]) >= 1 assert "could not scan" in capsys.readouterr().err + +def test_classify_docx(): + assert classify_file(Path("report.docx")) == FileType.DOCUMENT + +def test_classify_xlsx(): + assert classify_file(Path("data.xlsx")) == FileType.DOCUMENT + +def test_classify_pptx(): + assert classify_file(Path("slides.pptx")) == FileType.DOCUMENT + +import pytest + +def test_docx_to_markdown(tmp_path): + pytest.importorskip("docx") + from docx import Document + from graphify.detect import docx_to_markdown + doc = Document() + doc.add_heading("Title", level=1) + doc.add_paragraph("Hello world content here.") + path = tmp_path / "test.docx" + doc.save(path) + text = docx_to_markdown(path) + assert "Hello" in text or "Title" in text + +def test_xlsx_to_markdown(tmp_path): + pytest.importorskip("openpyxl") + import openpyxl + from graphify.detect import xlsx_to_markdown + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Revenue" + ws.append(["Month", "Amount", "Region"]) + ws.append(["Jan", 1000, "North"]) + path = tmp_path / "data.xlsx" + wb.save(path) + text = xlsx_to_markdown(path) + assert "Revenue" in text + assert "Month" in text + assert "Amount" in text + +def test_pptx_to_markdown(tmp_path): + pytest.importorskip("pptx") + from pptx import Presentation + from graphify.detect import pptx_to_markdown + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide.shapes.title.text = "Q4 Strategy" + path = tmp_path / "deck.pptx" + prs.save(path) + text = pptx_to_markdown(path) + assert "Q4 Strategy" in text + +def test_pptx_to_markdown_returns_empty_on_error(tmp_path): + from graphify.detect import pptx_to_markdown + fake = tmp_path / "corrupt.pptx" + fake.write_bytes(b"not a real pptx") + result = pptx_to_markdown(fake) + assert isinstance(result, str) + assert result == "" + +def test_count_words_docx(tmp_path): + pytest.importorskip("docx") + from docx import Document + doc = Document() + doc.add_heading("Title", level=1) + doc.add_paragraph("This document has several words in it.") + docx = tmp_path / "test.docx" + doc.save(docx) + from graphify.detect import count_words + assert count_words(docx) > 5 diff --git a/uv.lock b/uv.lock index 088ebbbdc..d0c631056 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.6" +version = "0.9.13" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1143,6 +1143,7 @@ all = [ { name = "openpyxl" }, { name = "pypdf" }, { name = "python-docx" }, + { name = "python-pptx" }, { name = "starlette" }, { name = "tiktoken" }, { name = "tree-sitter-dm" }, @@ -1191,6 +1192,7 @@ neo4j = [ office = [ { name = "openpyxl" }, { name = "python-docx" }, + { name = "python-pptx" }, ] ollama = [ { name = "openai" }, @@ -1285,6 +1287,8 @@ requires-dist = [ { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=6.12.0" }, { name = "python-docx", marker = "extra == 'all'" }, { name = "python-docx", marker = "extra == 'office'" }, + { name = "python-pptx", marker = "extra == 'all'" }, + { name = "python-pptx", marker = "extra == 'office'" }, { name = "rapidfuzz", specifier = ">=3.0" }, { name = "starlette", marker = "extra == 'all'", specifier = ">=1.3.1" }, { name = "starlette", marker = "extra == 'mcp'", specifier = ">=1.3.1" }, @@ -3343,6 +3347,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -5156,6 +5175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +] + [[package]] name = "yt-dlp" version = "2026.6.9"