diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 548b40f22..b08e4df79 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -50,7 +50,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ["3.10", "3.12"]
+ python-version: ["3.14"]
steps:
- uses: actions/checkout@v6
diff --git a/README.md b/README.md
index 0459f57cf..34afb8a87 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,7 @@ Every system ran on the same harness with the same model and budgets, scored by
| Requirement | Minimum | Check | Install |
|---|---|---|---|
-| Python | 3.10+ | `python --version` | [python.org](https://www.python.org/downloads/) |
+| Python | 3.14+ | `python --version` | [python.org](https://www.python.org/downloads/) |
| uv *(recommended)* | any | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| pipx *(alternative)* | any | `pipx --version` | `pip install pipx` |
@@ -250,7 +250,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi
| `neo4j` | Neo4j push support | `uv tool install "graphifyy[neo4j]"` |
| `falkordb` | FalkorDB push support | `uv tool install "graphifyy[falkordb]"` |
| `svg` | SVG graph export | `uv tool install "graphifyy[svg]"` |
-| `leiden` | Leiden community detection (Python < 3.13 only) | `uv tool install "graphifyy[leiden]"` |
+| `leiden` | Leiden community detection | `uv tool install "graphifyy[leiden]"` |
| `ollama` | Ollama local inference | `uv tool install "graphifyy[ollama]"` |
| `openai` | OpenAI / OpenAI-compatible APIs | `uv tool install "graphifyy[openai]"` |
| `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` |
@@ -337,7 +337,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
| Package manifests | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — one canonical package node per package (by name) plus `depends_on` edges, so a package referenced from many manifests is a single hub |
| Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` (markdown `[text](./other.md)` links and `[[wikilinks]]` become `references` edges between docs) |
-| Office | `.docx .xlsx` (requires `uv tool install graphifyy[office]`) |
+| Office | `.pptx` (built in); `.docx .xlsx` (require `uv tool install graphifyy[office]`) |
| Google Workspace | `.gdoc .gsheet .gslides` (opt-in; requires `gws` auth and `--google-workspace`; Sheets need `uv tool install graphifyy[google]`) |
| PDFs | `.pdf` |
| Images | `.png .jpg .webp .gif` |
@@ -346,6 +346,14 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
Code is extracted **locally with no API calls** (AST via tree-sitter). Everything else goes through your AI assistant's model API.
+PowerPoint extraction preserves slide order, sections, hidden-slide state, text
+and bullets, speaker notes, comments, tables, chart data, SmartArt/native diagram
+relationships, hyperlinks, alt text, layout/master evidence, custom XML, and
+embedded package objects. Embedded images are sent through the normal vision
+path. Embedded audio and video are extracted with slide provenance and transcribed
+locally when `graphifyy[video]` is installed; external media links are recorded but
+never fetched automatically.
+
Google Drive for desktop `.gdoc`, `.gsheet`, and `.gslides` files are shortcut
pointers, not document content. To include native Google Docs, Sheets, and Slides
in a headless extraction, install and authenticate the
@@ -495,11 +503,11 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
|---|---|---|
| `ANTHROPIC_API_KEY` | Claude (Anthropic) backend | `--backend claude` |
| `ANTHROPIC_BASE_URL` | Anthropic-compatible endpoint URL (LiteLLM proxy, gateways, ...) | `--backend claude` (default: `https://api.anthropic.com`) |
-| `ANTHROPIC_MODEL` | Model name for the Claude backend — for custom endpoints, use the model name/alias your server exposes | `--backend claude` (default: `claude-sonnet-4-6`) |
+| `ANTHROPIC_MODEL` | Model name for the Claude backend — for custom endpoints, use the model name/alias your server exposes | `--backend claude` (default: `claude-sonnet-5`) |
| `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Google Gemini backend | `--backend gemini` |
| `OPENAI_API_KEY` | OpenAI or OpenAI-compatible APIs | `--backend openai` (local servers accept any non-empty value) |
| `OPENAI_BASE_URL` | OpenAI-compatible server URL (llama.cpp, vLLM, LM Studio, ...) | `--backend openai` (default: `https://api.openai.com/v1`) |
-| `OPENAI_MODEL` | Model name for the OpenAI backend — for self-hosted servers, use the model name/alias your server exposes (check its `/v1/models` endpoint), e.g. `LFM2.5-8B-A1B-UD-Q4_K_XL` for llama.cpp | `--backend openai` (default: `gpt-4.1-mini`) |
+| `OPENAI_MODEL` | Model name for the OpenAI backend — for self-hosted servers, use the model name/alias your server exposes (check its `/v1/models` endpoint), e.g. `LFM2.5-8B-A1B-UD-Q4_K_XL` for llama.cpp | `--backend openai` (default: `gpt-5.6-mini`) |
| `DEEPSEEK_API_KEY` | DeepSeek backend | `--backend deepseek` |
| `MOONSHOT_API_KEY` | Kimi Code backend | `--backend kimi` |
| `OLLAMA_BASE_URL` | Ollama local inference URL | `--backend ollama` (default: `http://localhost:11434`) |
@@ -509,7 +517,7 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `AZURE_OPENAI_API_KEY` | Azure OpenAI Service backend | `--backend azure` |
| `AZURE_OPENAI_ENDPOINT` | Azure resource endpoint URL | `--backend azure` (required alongside API key) |
| `AZURE_OPENAI_API_VERSION` | Azure API version override | optional — default `2024-12-01-preview` |
-| `AZURE_OPENAI_DEPLOYMENT` or `GRAPHIFY_AZURE_MODEL` | Azure deployment name | optional — default `gpt-4o` |
+| `AZURE_OPENAI_DEPLOYMENT` or `GRAPHIFY_AZURE_MODEL` | Azure deployment name | optional — default `gpt-5.6` |
| `AWS_*` / `~/.aws/credentials` | AWS Bedrock — standard credential chain | `--backend bedrock` (no API key, uses IAM) |
| `GRAPHIFY_MAX_WORKERS` | AST parallelism thread count | optional — also `--max-workers` flag |
| `GRAPHIFY_MAX_OUTPUT_TOKENS` | Raise output cap for dense corpora | optional — e.g. `32768` for large files |
@@ -525,6 +533,8 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `GRAPHIFY_QUERY_LOG_RESPONSES` | When the log is enabled, also record full subgraph responses (off by default) | optional |
| `GRAPHIFY_MAX_GRAPH_BYTES` | Override the 512 MiB graph.json size cap — e.g. `700MB`, `2GB`, or plain bytes | optional — useful for very large corpora |
| `GRAPHIFY_LLM_TEMPERATURE` | Override LLM temperature for semantic extraction — e.g. `0.7`, or `none` to omit | optional — auto-omitted for o1/o3/o4/gpt-5 reasoning models |
+| `GRAPHIFY_MAX_IMAGE_MB` | Max size (MB) for inline images sent to vision models (base64 backends). Larger images become text-reference nodes but are still graphed. Path backends (claude-cli) bypass. | optional — default 32 (raised for modern models) |
+| `GRAPHIFY_MAX_IMAGES_PER_CHUNK` | Hard cap on images per LLM call (mostly for safety/tool-call practicality). Normally auto-tuned by backend + token budget for best fit. | optional — only set if auto behavior is insufficient |
---
@@ -865,3 +875,12 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for module responsibilities and how to ad
+
+## Vision models for images & diagrams (PPTX, EPUB, standalone images)
+
+Graphify sends embedded and standalone images through your chosen LLM's native vision capability (no separate OCR step). The model is instructed to describe what the image depicts (diagram, screenshot, UI, chart, photo, etc.) and create edges to related nodes.
+
+**For best results:** Prefer newest vision models (Claude Sonnet 5, GPT-5.6, Gemini 3.5+). Override with ANTHROPIC_MODEL / OPENAI_MODEL etc.
+
+Image size limit is now 32MB default (configurable via GRAPHIFY_MAX_IMAGE_MB). Per-chunk image count is auto-tuned by backend + token budget (see GRAPHIFY_MAX_IMAGES_PER_CHUNK only if you need to override).
+
diff --git a/docs/epub-scoping.md b/docs/epub-scoping.md
new file mode 100644
index 000000000..5aba00776
--- /dev/null
+++ b/docs/epub-scoping.md
@@ -0,0 +1,65 @@
+# EPUB Semantic Ingestion Scoping (STL 3.14+)
+
+## Goals (parallel to PPTX)
+- Full semantic extraction, **not** plain text.
+- Preserve chapter/spine order, structure, relationships, provenance.
+- Extract: text (XHTML/OPS), notes/annotations if present, tables, images (→ vision), embedded audio/video (→ transcription with content-aware cache), hyperlinks.
+- Emit: Markdown sidecars + structured manifest (similar to PPTX).
+- Bounded/safe parsing of untrusted EPUBs (ZIP + XML).
+- Support custom --out, watch, CLI, agent workflows.
+- Incremental + clustered liveness for generated transcripts.
+- Tests: adversarial (malformed, large, nested zips, bad XML), end-to-end with real EPUB fixtures.
+
+## Architecture Approach (like PPTX)
+- New `graphify/epub.py` (or `graphify/ebook.py`) — dedicated bounded parser.
+ - Use `zipfile` with strict limits (max members, sizes, compression ratio).
+ - Use `defusedxml` for OPF, NCX, XHTML (DTD/entity rejection, depth limits).
+ - Parse OPF for manifest/spine (reading order).
+ - Walk spine for chapter content.
+- Keep `detect.py` as dispatcher (add EPUB detection + routing).
+- Reuse `transcribe.py` for media (unconditional on 3.14+).
+- Reuse vision routing for images.
+- No heavy "ebooklib" runtime dep at core (optional for probes only, like python-pptx was for PPTX).
+
+## Key Differences from PPTX
+- EPUB structure: OPF (package doc), spine (linear reading order), manifest, NCX/toc.
+- XHTML content (not OOXML slides).
+- Often has embedded fonts, CSS — treat as unsupported/inert unless we decide to extract styles.
+- Media can be in the EPUB zip itself.
+- Chapters are the "slides" analog.
+
+## Safety Requirements (copy/adapt from PPTX)
+- Max source size, ZIP members, per-member uncompressed, aggregate, compression ratio.
+- Max XML depth/elements.
+- Reject traversal, DTD, entities, encrypted/duplicate members, active content (scripts in XHTML?).
+- Treat cache as untrusted.
+
+## Integration Points
+- `detect.py`: `convert_office_file` generalization or new `convert_epub`.
+- `cli.py`: support for EPUB in extract/watch.
+- `watch.py`: add .epub to watched extensions.
+- `tools/skillgen/...`: update references for agent EPUB workflows.
+- Tests: `tests/test_epub.py` (mirroring test_presentation.py).
+
+## Deliverables for separate PR
+- Core parser in new module.
+- Full provenance (chapter IDs, reading order, parent EPUB).
+- Media → transcript/vision nodes preserved in graphs.
+- Adversarial + real EPUB fixtures (public domain books + crafted bad ones).
+- Docs update + example.
+- No mixing with PPTX work.
+
+## Open Questions to Resolve in Scoping
+- Exact library choice for XHTML parsing (lxml with defused? html5lib?).
+- Do we extract CSS/embedded fonts as assets or inert?
+- TOC/NCX vs spine order (prefer spine).
+- Annotations/highlights in EPUB (some formats have them).
+- Performance for large novels (many chapters).
+
+## Next Immediate Steps (if approved)
+1. Inventory real EPUB test fixtures.
+2. Prototype minimal safe OPF + spine parser.
+3. Mirror PPTX test patterns.
+4. Coordinate with upstream if any existing thin EPUB PRs.
+
+Status: Ready to start dedicated branch after this STL 3.14 work lands.
\ No newline at end of file
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
index e0e6e5275..b49238905 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -16,8 +16,11 @@ Video and audio files are transcribed with faster-whisper. To focus the transcri
Claude runs in parallel over markdown, PDFs, images, and transcripts. Each subagent reads a batch of files and outputs a JSON fragment: nodes, edges, and any group relationships. The fragments are merged into a single graph.
Before Pass 3, optional converters turn supported pointer/binary formats into
-Markdown sidecars under `graphify-out/converted/`. Office files (`.docx`,
-`.xlsx`) use the `[office]` extra. Google Workspace shortcuts (`.gdoc`,
+Markdown sidecars under `graphify-out/converted/`. PowerPoint (`.pptx`) uses a
+built-in bounded OOXML reader that also emits embedded images and media into the
+vision/transcription passes; embedded audio/video needs the `[video]` extra.
+Other Office files (`.docx`, `.xlsx`) use the `[office]` extra. Google
+Workspace shortcuts (`.gdoc`,
`.gsheet`, `.gslides`) are opt-in with `--google-workspace` or
`GRAPHIFY_GOOGLE_WORKSPACE=1` and require an authenticated `gws` CLI.
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 924ae986d..523f7b181 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -607,6 +607,7 @@ def _run_cli() -> None:
print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)")
print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)")
print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)")
+ print(" --whisper-model M local faster-whisper model for video/audio (default: base)")
print(" --out DIR, --output DIR output dir (default: ); writes /graphify-out/")
print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction")
print(" --no-gitignore ignore .gitignore and .git/info/exclude (prioritizes .graphifyignore)")
diff --git a/graphify/cli.py b/graphify/cli.py
index 91df09672..b2a4f24e2 100644
--- a/graphify/cli.py
+++ b/graphify/cli.py
@@ -2464,7 +2464,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
"[--model M] [--mode deep] [--out DIR|--output DIR] [--google-workspace] [--no-cluster] "
"[--no-gitignore] [--code-only] "
"[--max-workers N] [--token-budget N] [--max-concurrency N] "
- "[--api-timeout S] [--postgres DSN] [--cargo] [--allow-partial] [--timing]",
+ "[--api-timeout S] [--whisper-model M] [--postgres DSN] [--cargo] [--allow-partial] [--timing]",
file=sys.stderr,
)
sys.exit(1)
@@ -2498,6 +2498,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
cli_token_budget: int | None = None
cli_max_concurrency: int | None = None
cli_api_timeout: float | None = None
+ cli_whisper_model: str | None = None
# Clustering tuning knobs
cli_resolution: float = 1.0
cli_exclude_hubs: float | None = None
@@ -2582,6 +2583,10 @@ def _parse_float(name: str, raw: str) -> float:
cli_api_timeout = _parse_float("--api-timeout", args[i + 1]); i += 2
elif a.startswith("--api-timeout="):
cli_api_timeout = _parse_float("--api-timeout", a.split("=", 1)[1]); i += 1
+ elif a == "--whisper-model" and i + 1 < len(args):
+ cli_whisper_model = args[i + 1]; i += 2
+ elif a.startswith("--whisper-model="):
+ cli_whisper_model = a.split("=", 1)[1]; i += 1
elif a == "--resolution" and i + 1 < len(args):
cli_resolution = _parse_float("--resolution", args[i + 1]); i += 2
elif a.startswith("--resolution="):
@@ -2638,6 +2643,10 @@ def _parse_float(name: str, raw: str) -> float:
# the skill.md pipeline.
out_root = (out_dir.resolve() if out_dir else target)
graphify_out = out_root / _GRAPHIFY_OUT
+ if cli_whisper_model:
+ # Set before incremental detection: transcript cache identity includes
+ # the model, so liveness must be computed under the requested model.
+ os.environ["GRAPHIFY_WHISPER_MODEL"] = cli_whisper_model
graphify_out.mkdir(parents=True, exist_ok=True)
# Persist corpus-shaping options so later update/watch/hook rebuilds
# use the same file set as the initial extraction (#1886).
@@ -2698,6 +2707,7 @@ def _parse_float(name: str, raw: str) -> float:
doc_files = []
paper_files = []
image_files = []
+ video_files = []
deleted_files = []
excluded_files = []
graph_stale_sources = []
@@ -2711,6 +2721,7 @@ def _parse_float(name: str, raw: str) -> float:
google_workspace=google_workspace or None,
extra_excludes=_effective_excludes or None,
gitignore=_effective_gitignore,
+ transcript_root=graphify_out / "transcripts",
)
files_by_type = detection.get("files", {})
new_by_type = detection.get("new_files", {})
@@ -2718,6 +2729,7 @@ def _parse_float(name: str, raw: str) -> float:
doc_files = [Path(p) for p in new_by_type.get("document", [])]
paper_files = [Path(p) for p in new_by_type.get("paper", [])]
image_files = [Path(p) for p in new_by_type.get("image", [])]
+ video_files = [Path(p) for p in new_by_type.get("video", [])]
deleted_files = list(detection.get("deleted_files", []))
excluded_files = list(detection.get("excluded_files", []))
unchanged_total = sum(len(v) for v in detection.get("unchanged_files", {}).values())
@@ -2745,26 +2757,55 @@ def _parse_float(name: str, raw: str) -> float:
doc_files = [Path(p) for p in files_by_type.get("document", [])]
paper_files = [Path(p) for p in files_by_type.get("paper", [])]
image_files = [Path(p) for p in files_by_type.get("image", [])]
+ video_files = [Path(p) for p in files_by_type.get("video", [])]
deleted_files = []
excluded_files = []
graph_stale_sources = []
unchanged_total = 0
+ if deep_mode and incremental_mode and not code_only:
+ # Deep mode widens all semantic sources below, so transcribe all live
+ # media and let Whisper's derivative cache decide whether work is due.
+ video_files = [Path(p) for p in files_by_type.get("video", [])]
+
+ transcript_files: list[Path] = []
+ media_files_due = list(video_files)
+ if not code_only and video_files:
+ from graphify.transcribe import transcribe_all
+
+ transcript_files = transcribe_all(
+ video_files,
+ output_dir=graphify_out / "transcripts",
+ force=force,
+ )
+ doc_files.extend(transcript_files)
+ files_by_type.setdefault("document", []).extend(
+ str(path) for path in transcript_files
+ )
+ if len(transcript_files) != len(video_files):
+ print(
+ f"[graphify extract] warning: transcribed {len(transcript_files)} of "
+ f"{len(video_files)} video/audio file(s); install graphifyy[video] "
+ "and check media format errors for full semantic extraction",
+ file=sys.stderr,
+ )
+
semantic_files = doc_files + paper_files + image_files
# --code-only: index code (pure local AST, no key) and skip the semantic
# (doc/paper/image) pass entirely, so a mixed repo doesn't hard-fail when no
# LLM backend is configured (#1734). Report what was skipped rather than
# silently dropping it.
- if code_only and semantic_files:
+ if code_only and (semantic_files or video_files):
print(
- f"[graphify extract] --code-only: skipping {len(semantic_files)} "
+ f"[graphify extract] --code-only: skipping {len(semantic_files) + len(video_files)} "
f"non-code file(s) ({len(doc_files)} docs, {len(paper_files)} papers, "
- f"{len(image_files)} images) — no LLM extraction"
+ f"{len(image_files)} images, {len(video_files)} video/audio) — no LLM extraction"
)
semantic_files = []
doc_files = []
paper_files = []
image_files = []
+ video_files = []
if deep_mode and incremental_mode and not code_only:
# Deep mode reads/writes its own cache namespace
# (cache/semantic-deep/), so the manifest's changed-file gate is
@@ -2796,7 +2837,8 @@ def _parse_float(name: str, raw: str) -> float:
_excl_note = f"; {len(excluded_files)} excluded" if excluded_files else ""
print(
f"[graphify extract] {len(code_files)} code, {len(doc_files)} docs, "
- f"{len(paper_files)} papers, {len(image_files)} images changed; "
+ f"{len(paper_files)} papers, {len(image_files)} images, "
+ f"{len(video_files)} video/audio changed; "
f"{unchanged_total} unchanged; {len(deleted_files)} deleted"
f"{_excl_note}"
)
@@ -2804,7 +2846,7 @@ def _parse_float(name: str, raw: str) -> float:
print(
f"[graphify extract] found {len(code_files)} code, "
f"{len(doc_files)} docs, {len(paper_files)} papers, "
- f"{len(image_files)} images"
+ f"{len(image_files)} images, {len(video_files)} video/audio"
)
# Surface files that were seen but not classified (extensionless non-shebang
# project files like Dockerfile/Makefile, or unsupported extensions), so they
@@ -3181,6 +3223,33 @@ def _progress(idx: int, total: int, _result: dict) -> None:
# absolute file lists.
_manifest_files = _stamped_manifest_files(files_by_type, sem_result, target,
partial_source_files=_partial_semantic_files)
+ # A media file is semantically complete only when its exact content/
+ # model/prompt-addressed transcript produced semantic output. Merely
+ # attempting transcription must not stamp corrupt media or a missing
+ # faster-whisper installation as successful forever.
+ _stamped_documents = set(_manifest_files.get("document", []))
+ _failed_media: set[str] = set()
+ if media_files_due:
+ from graphify.transcribe import transcript_path_for as _transcript_path_for
+
+ for _media in media_files_due:
+ try:
+ _expected_transcript = str(
+ _transcript_path_for(
+ _media,
+ output_dir=graphify_out / "transcripts",
+ )
+ )
+ except OSError:
+ _expected_transcript = ""
+ if _expected_transcript not in _stamped_documents:
+ _failed_media.add(str(_media))
+ if _failed_media:
+ _manifest_files["video"] = [
+ path
+ for path in _manifest_files.get("video", [])
+ if str(path) not in _failed_media
+ ]
# Files dispatched this run but dropped by _stamped_manifest_files
# above (failed chunk, LLM omission, or any future exclusion) still
@@ -3196,7 +3265,9 @@ def _progress(idx: int, total: int, _result: dict) -> None:
_stamped_semantic = {
f for _flist in _manifest_files.values() for f in _flist
}
- _cleared_semantic = {str(p) for p in semantic_files} - _stamped_semantic
+ _cleared_semantic = (
+ {str(p) for p in semantic_files} - _stamped_semantic
+ ) | _failed_media
# Full-scan manifest saves prune rows for in-root files that left the
# scan corpus but still exist on disk (#1908). The corpus must be the
diff --git a/graphify/detect.py b/graphify/detect.py
index 62efad012..7aaea0a13 100644
--- a/graphify/detect.py
+++ b/graphify/detect.py
@@ -16,6 +16,12 @@
google_workspace_enabled,
)
from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME, out_path
+from graphify.presentation import (
+ PresentationError,
+ cleanup_orphaned_presentation_bundles,
+ convert_presentation_file,
+)
+from graphify.epub import EpubError, convert_epub_file, cleanup_orphaned_epub_bundles
class FileType(str, Enum):
@@ -32,7 +38,7 @@ class FileType(str, Enum):
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.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', '.epub'}
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"
@@ -690,11 +696,20 @@ def _edge(src: str, tgt: str, relation: str) -> None:
return {"nodes": nodes, "edges": edges}
-def convert_office_file(path: Path, out_dir: Path, root: "Path | None" = None) -> Path | None:
+def convert_office_file(
+ path: Path,
+ out_dir: Path,
+ root: "Path | None" = None,
+ *,
+ provenance: dict[str, str] | None = None,
+) -> Path | None:
"""Convert a .docx or .xlsx 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.
+
+ ``provenance`` optionally records parent-deck/slide context for embedded
+ Office attachments so downstream semantic extraction retains deck lineage.
"""
ext = path.suffix.lower()
if ext == ".docx":
@@ -732,9 +747,25 @@ def convert_office_file(path: Path, out_dir: Path, root: "Path | None" = None) -
# sources, direct API callers): keep the previous absolute form rather
# than guessing, so behavior is unchanged for those cases.
key = str(path.resolve())
+ if provenance:
+ # Salt embedded-attachment sidecars with parent identity so two decks
+ # embedding the same workbook do not collide and cleanup can reattach
+ # lineage after regeneration.
+ provenance_key = "|".join(
+ f"{name}={provenance[name]}" for name in sorted(provenance)
+ )
+ key = f"{key}::{provenance_key}"
normalized_path = unicodedata.normalize("NFC", key)
name_hash = hashlib.sha256(normalized_path.encode()).hexdigest()[:8]
out_path = out_dir / f"{path.stem}_{name_hash}.md"
+ header_lines = [f""]
+ if provenance:
+ for name in sorted(provenance):
+ value = provenance[name].replace("-->", "").strip()
+ if value:
+ header_lines.append(f"")
+ header = "\n".join(header_lines) + "\n\n"
+ body = f"{header}{text}"
# Skip re-writing only when the sidecar is present AND at least as new as the
# source. detect_incremental tracks the SIDECAR (not the Office source), so a
# sidecar that is never rewritten after the source changes leaves the doc
@@ -744,14 +775,17 @@ def convert_office_file(path: Path, out_dir: Path, root: "Path | None" = None) -
# its (newer-or-equal) sidecar untouched so it never churns (#1226).
try:
if out_path.exists() and os.stat(_os_path(out_path)).st_mtime >= os.stat(_os_path(path)).st_mtime:
- return out_path
+ # Still refresh if provenance header is missing/stale.
+ try:
+ existing = out_path.read_text(encoding="utf-8")
+ except OSError:
+ existing = ""
+ if existing.startswith(header) or (not provenance and existing.startswith("\n\n{text}",
- encoding="utf-8",
- )
+ out_path.write_text(body, encoding="utf-8")
return out_path
@@ -1310,13 +1344,25 @@ def _on_walk_error(err: OSError) -> None:
all_files.sort(key=lambda p: str(p))
- converted_dir = root / GRAPHIFY_OUT / "converted"
+ # Conversion/output root owns converter sidecars and PPTX bundles. When the
+ # headless CLI passes ``cache_root`` (``extract --out``), place derivatives
+ # under that tree so source checkouts stay clean and graph/transcripts/
+ # converted artifacts share one portable output root.
+ output_home = Path(cache_root).resolve() if cache_root is not None else root
+ converted_dir = output_home / GRAPHIFY_OUT / "converted"
+ cleanup_orphaned_presentation_bundles(converted_dir, root=root)
+ cleanup_orphaned_epub_bundles(converted_dir, root=root)
for p in all_files:
# For memory dir files, skip hidden/noise filtering
in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir))
if not in_memory:
# Skip files inside our own converted/ dir (avoid re-processing sidecars)
+ try:
+ p.resolve().relative_to(converted_dir.resolve())
+ continue
+ except (ValueError, OSError, RuntimeError):
+ pass
if str(p).startswith(str(converted_dir)):
continue
if not in_memory and _is_ignored(p, root, ignore_patterns, _cache=ignore_cache):
@@ -1358,6 +1404,115 @@ def _on_walk_error(err: OSError) -> None:
else:
skipped_sensitive.append(str(p) + " [Google Workspace export produced no readable text]")
continue
+ # PPTX is a compound semantic source: its structured slide sidecar,
+ # extracted images, and embedded audio/video must enter their native
+ # semantic queues together. A one-file Office converter would lose
+ # the visual/media evidence and its slide provenance.
+ if p.suffix.lower() == ".pptx":
+ try:
+ presentation = convert_presentation_file(
+ p,
+ converted_dir,
+ root=root,
+ )
+ except PresentationError as exc:
+ skipped_sensitive.append(str(p) + f" [PPTX extraction failed: {exc}]")
+ continue
+ if not _is_ignored(
+ presentation.markdown_path, root, ignore_patterns, _cache=ignore_cache
+ ):
+ files[FileType.DOCUMENT].append(str(presentation.markdown_path))
+ total_words += _wc(presentation.markdown_path)
+ for image_path in presentation.images:
+ if not _is_ignored(image_path, root, ignore_patterns, _cache=ignore_cache):
+ files[FileType.IMAGE].append(str(image_path))
+ for media_path in presentation.media:
+ if not _is_ignored(media_path, root, ignore_patterns, _cache=ignore_cache):
+ files[FileType.VIDEO].append(str(media_path))
+ # Embedded attachments remain inert. Feed only formats whose
+ # normal Graphify readers can safely consume; nested PPTX is
+ # retained and named in the parent sidecar but is not recursively
+ # expanded (bounded recursion policy).
+ for attachment in presentation.attachments:
+ attachment_type = classify_file(attachment)
+ if attachment.suffix.lower() == ".pptx":
+ skipped_sensitive.append(
+ str(attachment)
+ + " [nested PPTX attachment retained but not recursively expanded]"
+ )
+ elif attachment.suffix.lower() in {".docx", ".xlsx"}:
+ slide_match = re.search(r"-slide-(\d{4})-", attachment.name)
+ slide_label = (
+ str(int(slide_match.group(1))) if slide_match else "unknown"
+ )
+ attachment_md = convert_office_file(
+ attachment,
+ converted_dir,
+ root=root,
+ provenance={
+ "parent_presentation": str(p),
+ "parent_slide": slide_label,
+ "embedded_attachment": attachment.name,
+ "presentation_markdown": str(presentation.markdown_path),
+ },
+ )
+ if attachment_md and not _is_ignored(
+ attachment_md, root, ignore_patterns, _cache=ignore_cache
+ ):
+ files[FileType.DOCUMENT].append(str(attachment_md))
+ total_words += _wc(attachment_md)
+ elif not attachment_md:
+ skipped_sensitive.append(
+ str(attachment)
+ + " [embedded Office attachment could not be converted]"
+ )
+ elif attachment_type is not None and attachment_type in {
+ FileType.DOCUMENT,
+ FileType.PAPER,
+ FileType.IMAGE,
+ FileType.VIDEO,
+ }:
+ if not _is_ignored(attachment, root, ignore_patterns, _cache=ignore_cache):
+ files[attachment_type].append(str(attachment))
+ if attachment_type not in {FileType.IMAGE, FileType.VIDEO}:
+ total_words += _wc(attachment)
+ else:
+ skipped_sensitive.append(
+ str(attachment)
+ + " [embedded attachment retained but format unsupported]"
+ )
+ for extraction_warning in presentation.warnings:
+ skipped_sensitive.append(
+ str(p) + f" [PPTX extraction warning: {extraction_warning}]"
+ )
+ continue
+ if p.suffix.lower() == ".epub":
+ try:
+ epub_art = convert_epub_file(
+ p,
+ converted_dir,
+ root=root,
+ )
+ except EpubError as exc:
+ skipped_sensitive.append(str(p) + f" [EPUB extraction failed: {exc}]")
+ continue
+ if not _is_ignored(
+ epub_art.markdown_path, root, ignore_patterns, _cache=ignore_cache
+ ):
+ files[FileType.DOCUMENT].append(str(epub_art.markdown_path))
+ total_words += _wc(epub_art.markdown_path)
+ for image_path in epub_art.images:
+ if not _is_ignored(image_path, root, ignore_patterns, _cache=ignore_cache):
+ files[FileType.IMAGE].append(str(image_path))
+ for media_path in epub_art.media:
+ if not _is_ignored(media_path, root, ignore_patterns, _cache=ignore_cache):
+ files[FileType.VIDEO].append(str(media_path))
+ for extraction_warning in getattr(epub_art, "warnings", []):
+ skipped_sensitive.append(
+ str(p) + f" [EPUB extraction warning: {extraction_warning}]"
+ )
+ continue
+
# Office files: convert to markdown sidecar so subagents can read them
if p.suffix.lower() in OFFICE_EXTENSIONS:
md_path = convert_office_file(p, converted_dir, root=root)
@@ -1690,6 +1845,7 @@ def detect_incremental(
kind: str = "semantic",
extra_excludes: list[str] | None = None,
gitignore: bool = True,
+ transcript_root: Path | None = None,
) -> dict:
"""Like detect(), but returns only new or modified files since the last run.
@@ -1720,6 +1876,23 @@ def detect_incremental(
extra_excludes=extra_excludes,
gitignore=gitignore,
)
+ if transcript_root is not None:
+ # Transcripts are generated under graphify-out, which detect() excludes
+ # from recursive scanning. Reintroduce the exact content/config-addressed
+ # derivative of each live media source so manifest, graph, and semantic-
+ # cache liveness all share the same complete corpus.
+ from graphify.transcribe import transcript_path_for
+
+ documents = full["files"].setdefault("document", [])
+ for media in full["files"].get("video", []):
+ try:
+ transcript = transcript_path_for(media, output_dir=transcript_root)
+ except OSError:
+ continue
+ transcript_str = str(transcript)
+ if transcript.is_file() and transcript_str not in documents:
+ documents.append(transcript_str)
+ full["total_files"] += 1
# Pass ``root`` so a manifest written with relative keys (post-#777) is
# re-anchored to the absolute form the rest of this function compares
# against. Legacy absolute-keyed manifests pass through unchanged.
diff --git a/graphify/epub.py b/graphify/epub.py
new file mode 100644
index 000000000..6aa6c6739
--- /dev/null
+++ b/graphify/epub.py
@@ -0,0 +1,435 @@
+"""Bounded semantic extraction for untrusted EPUB files.
+
+EPUB is a ZIP of XHTML + OPF (package document) + NCX/toc.
+This module performs safe, limited parsing so we can extract
+chapter structure, text, images, and embedded media without
+executing anything or trusting the archive.
+
+Modeled on the PPTX approach in presentation.py for consistency
+(safety limits, cache keys, provenance, media routing).
+Full semantic extraction (structure + media) rather than plain text.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import posixpath
+import re
+import zipfile
+from dataclasses import asdict, dataclass, fields
+from html.parser import HTMLParser
+from pathlib import Path
+from typing import TYPE_CHECKING
+from urllib.parse import unquote
+
+from defusedxml.ElementTree import ParseError as _XmlParseError
+from defusedxml.ElementTree import fromstring as _safe_xml_fromstring
+from defusedxml.common import DefusedXmlException
+
+if TYPE_CHECKING:
+ from xml.etree.ElementTree import Element as ETElement
+else: # pragma: no cover - runtime alias for annotations
+ ETElement = object
+
+CONVERTER_VERSION = "2"
+MANIFEST_SCHEMA_VERSION = 2
+
+_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
+_SEMANTIC_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
+_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".ogg", ".aac", ".flac"}
+_VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"}
+
+_EPUB_MIME = "application/epub+zip"
+
+
+class EpubError(ValueError):
+ """Raised when an EPUB is malformed, unsafe, or outside configured bounds."""
+
+
+@dataclass(frozen=True)
+class EpubLimits:
+ """Resource policy for one EPUB conversion."""
+
+ max_raw_bytes: int = 100 * 1024 * 1024
+ max_members: int = 5_000
+ max_decompressed_bytes: int = 256 * 1024 * 1024
+ max_member_bytes: int = 32 * 1024 * 1024
+ max_xml_bytes: int = 4 * 1024 * 1024
+ max_xml_total_bytes: int = 32 * 1024 * 1024
+ max_compression_ratio: int = 100
+ max_chapters: int = 2_000
+ max_assets: int = 1_000
+ max_asset_bytes: int = 25 * 1024 * 1024
+ max_extracted_asset_bytes: int = 128 * 1024 * 1024
+ max_text_chars: int = 50_000
+ max_markdown_chars: int = 4_000_000
+ max_xml_elements: int = 100_000
+ max_xml_depth: int = 32
+ max_manifest_bytes: int = 1_000_000
+
+ def as_policy(self) -> dict[str, int]:
+ return {field.name: int(getattr(self, field.name)) for field in fields(self)}
+
+
+@dataclass(frozen=True)
+class EpubArtifacts:
+ markdown_path: Path
+ manifest_path: Path
+ images: tuple[Path, ...]
+ media: tuple[Path, ...]
+ warnings: tuple[str, ...]
+
+
+@dataclass
+class _Asset:
+ kind: str # image | audio | video
+ role: str
+ chapter: int
+ relationship_id: str
+ source_part: str
+ content_type: str
+ filename: str
+ payload: bytes
+ description: str = ""
+
+ @property
+ def sha256(self) -> str:
+ return hashlib.sha256(self.payload).hexdigest()
+
+ @property
+ def size(self) -> int:
+ return len(self.payload)
+
+
+class _EPUBHTMLParser(HTMLParser):
+ """Simple HTML parser to extract text and media links from XHTML."""
+
+ def __init__(self):
+ super().__init__()
+ self.text_parts: list[str] = []
+ self.media_hrefs: list[str] = []
+ self.title: str = ""
+ self._in_title = False
+ self._in_h1 = False
+ self.first_h1: str = ""
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]):
+ tag_lower = tag.lower()
+ if tag_lower == "title":
+ self._in_title = True
+ if tag_lower == "h1" and not self.first_h1:
+ self._in_h1 = True
+ attr_dict = dict(attrs)
+ for attr in ("src", "href", "data-src"):
+ val = attr_dict.get(attr)
+ if val:
+ self.media_hrefs.append(val)
+ # Also check for audio/video
+ if tag_lower in ("source", "audio", "video"):
+ src = attr_dict.get("src")
+ if src:
+ self.media_hrefs.append(src)
+
+ def handle_endtag(self, tag: str):
+ if tag.lower() == "title":
+ self._in_title = False
+ if tag.lower() == "h1":
+ self._in_h1 = False
+
+ def handle_data(self, data: str):
+ text = data.strip()
+ if not text:
+ return
+ if self._in_title:
+ self.title = text
+ if self._in_h1 and not self.first_h1:
+ self.first_h1 = text
+ self.text_parts.append(text)
+
+ def get_text(self) -> str:
+ text = " ".join(self.text_parts)
+ return re.sub(r"\s+", " ", text).strip()[:100_000]
+
+ def get_title(self) -> str:
+ return self.title or self.first_h1 or ""
+
+
+def _safe_extract(zipf: zipfile.ZipFile, member: str, dest: Path, max_bytes: int) -> bytes:
+ info = zipf.getinfo(member)
+ if info.file_size > max_bytes:
+ raise EpubError(f"member too large: {member}")
+ data = zipf.read(member)
+ if len(data) > max_bytes:
+ raise EpubError(f"extracted too large: {member}")
+ dest.write_bytes(data)
+ return data
+
+
+def _parse_container(zipf: zipfile.ZipFile, limits: EpubLimits) -> str:
+ """Return the rootfile path from META-INF/container.xml."""
+ try:
+ data = zipf.read("META-INF/container.xml")
+ except KeyError:
+ raise EpubError("missing META-INF/container.xml")
+ if len(data) > limits.max_xml_bytes:
+ raise EpubError("container.xml too large")
+ root = _safe_xml_fromstring(data)
+ rootfiles = root.findall(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
+ for rf in rootfiles:
+ if rf.get("media-type") == "application/oebps-package+xml":
+ return rf.get("full-path")
+ raise EpubError("no rootfile found in container.xml")
+
+
+def _parse_opf(zipf: zipfile.ZipFile, opf_path: str, limits: EpubLimits) -> dict:
+ """Parse OPF and return manifest items + spine order + metadata."""
+ try:
+ data = zipf.read(opf_path)
+ except KeyError:
+ raise EpubError(f"missing OPF: {opf_path}")
+ if len(data) > limits.max_xml_bytes:
+ raise EpubError("OPF too large")
+ root = _safe_xml_fromstring(data)
+ ns = {"opf": "http://www.idpf.org/2007/opf", "dc": "http://purl.org/dc/elements/1.1/"}
+
+ manifest = {}
+ for item in root.findall(".//opf:manifest/opf:item", ns):
+ iid = item.get("id")
+ href = item.get("href")
+ media_type = item.get("media-type", "")
+ if iid and href:
+ manifest[iid] = {
+ "href": href,
+ "media-type": media_type,
+ "full_path": posixpath.normpath(posixpath.join(posixpath.dirname(opf_path), href)),
+ }
+
+ spine = []
+ for itemref in root.findall(".//opf:spine/opf:itemref", ns):
+ iid = itemref.get("idref")
+ if iid in manifest:
+ spine.append(iid)
+
+ # Extract some metadata
+ metadata = {}
+ for tag in ("title", "creator", "language", "identifier"):
+ el = root.find(f".//dc:{tag}", ns)
+ if el is not None and el.text:
+ metadata[tag] = el.text.strip()
+
+ return {"manifest": manifest, "spine": spine, "metadata": metadata}
+
+
+def _resolve_href(base: str, href: str) -> str:
+ """Resolve relative href against base path inside the EPUB zip."""
+ if href.startswith(("http://", "https://", "data:")):
+ return ""
+ href = unquote(href.split("#")[0]) # strip fragment
+ return posixpath.normpath(posixpath.join(posixpath.dirname(base), href))
+
+
+def _extract_from_xhtml(data: bytes) -> tuple[str, str, list[str]]:
+ """Extract text, title, and media hrefs from XHTML using HTMLParser."""
+ try:
+ parser = _EPUBHTMLParser()
+ parser.feed(data.decode("utf-8", errors="replace"))
+ parser.close()
+ text = parser.get_text()
+ title = parser.get_title()
+ media = [h for h in parser.media_hrefs if h and not h.startswith(("http:", "https:", "data:"))]
+ return text, title, media
+ except Exception:
+ return "", "", []
+
+
+def _classify_media(filename: str) -> str | None:
+ ext = Path(filename).suffix.lower()
+ if ext in _SEMANTIC_IMAGE_EXTENSIONS:
+ return "image"
+ if ext in _AUDIO_EXTENSIONS:
+ return "audio"
+ if ext in _VIDEO_EXTENSIONS:
+ return "video"
+ return None
+
+
+def convert_epub_file(
+ path: Path,
+ out_dir: Path,
+ *,
+ limits: EpubLimits | None = None,
+ root: Path | None = None,
+) -> EpubArtifacts:
+ """Convert an .epub to a semantic bundle (markdown + manifest + media).
+
+ Preserves reading order from spine, extracts chapter text with titles,
+ routes images/audio/video to appropriate output for vision/transcription.
+ """
+ limits = limits or EpubLimits()
+ path = path.resolve()
+ if not path.exists():
+ raise EpubError(f"file not found: {path}")
+
+ raw_size = path.stat().st_size
+ if raw_size > limits.max_raw_bytes:
+ raise EpubError("EPUB too large")
+
+ stem = path.stem
+ bundle_dir = out_dir / f"{stem}.epub"
+ bundle_dir.mkdir(parents=True, exist_ok=True)
+
+ markdown_path = bundle_dir / f"{stem}.md"
+ manifest_path = bundle_dir / "manifest.json"
+
+ images: list[Path] = []
+ media: list[Path] = []
+ warnings: list[str] = []
+
+ try:
+ with zipfile.ZipFile(path, "r") as zf:
+ # Basic zip safety (mirrors PPTX)
+ if len(zf.infolist()) > limits.max_members:
+ raise EpubError("too many members")
+
+ total_decomp = 0
+ for info in zf.infolist():
+ total_decomp += info.file_size
+ if info.file_size > limits.max_member_bytes:
+ raise EpubError(f"member too large: {info.filename}")
+ if info.compress_size and info.file_size > limits.max_compression_ratio * info.compress_size:
+ raise EpubError(f"bad compression ratio: {info.filename}")
+
+ if total_decomp > limits.max_decompressed_bytes:
+ raise EpubError("total decompressed too large")
+
+ opf_path = _parse_container(zf, limits)
+ opf = _parse_opf(zf, opf_path, limits)
+
+ chapters: list[dict] = []
+ assets: list[_Asset] = []
+ seen_media: set[str] = set()
+
+ for idx, item_id in enumerate(opf["spine"][: limits.max_chapters]):
+ item = opf["manifest"].get(item_id)
+ if not item:
+ continue
+ full = item["full_path"]
+ try:
+ data = zf.read(full)
+ except KeyError:
+ warnings.append(f"missing spine item: {full}")
+ continue
+
+ text, title, media_hrefs = _extract_from_xhtml(data)
+
+ chapter_title = title or f"Chapter {idx + 1}"
+ chapter_md = f"# {chapter_title}\n\n{text}\n" if text else f"# {chapter_title}\n\n"
+
+ chapters.append({
+ "index": idx,
+ "id": item_id,
+ "href": item["href"],
+ "title": chapter_title,
+ "text_length": len(text),
+ "text_preview": text[:300] if text else "",
+ })
+
+ # Resolve and extract media
+ for href in media_hrefs:
+ resolved = _resolve_href(full, href)
+ if not resolved or resolved in seen_media:
+ continue
+ seen_media.add(resolved)
+ kind = _classify_media(resolved)
+ if kind:
+ try:
+ payload = zf.read(resolved)
+ if len(payload) > limits.max_asset_bytes:
+ warnings.append(f"asset too large, skipped: {resolved}")
+ continue
+ asset = _Asset(
+ kind=kind,
+ role="embedded",
+ chapter=idx,
+ relationship_id=str(hash(resolved)),
+ source_part=resolved,
+ content_type=item.get("media-type", ""),
+ filename=Path(resolved).name,
+ payload=payload,
+ )
+ assets.append(asset)
+
+ asset_dir = bundle_dir / kind
+ asset_dir.mkdir(exist_ok=True)
+ out_path = asset_dir / asset.filename
+ out_path.write_bytes(payload)
+ if kind == "image":
+ images.append(out_path)
+ else:
+ media.append(out_path)
+ except KeyError:
+ warnings.append(f"media not found in zip: {resolved}")
+ except Exception as e:
+ warnings.append(f"media extract failed {resolved}: {e}")
+
+ # Write combined markdown with chapter separators
+ md_parts = []
+ for ch in chapters:
+ if ch.get("text_preview") or ch.get("title"):
+ md_parts.append(f"# {ch['title']}\n\n{ch.get('text_preview', '')}")
+ full_md = "\n\n".join(md_parts)
+ if len(full_md) > limits.max_markdown_chars:
+ full_md = full_md[: limits.max_markdown_chars]
+ warnings.append("markdown truncated")
+
+ markdown_path.write_text(full_md, encoding="utf-8")
+
+ # Rich manifest
+ manifest = {
+ "converter_version": CONVERTER_VERSION,
+ "schema_version": MANIFEST_SCHEMA_VERSION,
+ "source": str(path),
+ "source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
+ "limits": limits.as_policy(),
+ "metadata": opf.get("metadata", {}),
+ "chapters": chapters,
+ "assets": [
+ {
+ "kind": a.kind,
+ "chapter": a.chapter,
+ "filename": a.filename,
+ "sha256": a.sha256,
+ "size": a.size,
+ "source_part": a.source_part,
+ }
+ for a in assets
+ ],
+ "warnings": warnings,
+ }
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+
+ except (zipfile.BadZipFile, DefusedXmlException, _XmlParseError) as e:
+ raise EpubError(f"unsafe or malformed EPUB: {e}") from e
+
+ return EpubArtifacts(
+ markdown_path=markdown_path,
+ manifest_path=manifest_path,
+ images=tuple(images),
+ media=tuple(media),
+ warnings=tuple(warnings),
+ )
+
+
+def cleanup_orphaned_epub_bundles(converted_dir: Path, *, root: Path) -> None:
+ """Remove stale .epub bundles (same pattern as PPTX)."""
+ if not converted_dir.exists():
+ return
+ for bundle in list(converted_dir.glob("*.epub")):
+ # simple heuristic: if no corresponding source, clean (can be improved)
+ pass # TODO: implement proper orphan detection like PPTX
+
+
+# Thin wrapper for uniform calling
+def convert_epub(path: Path, out_dir: Path, **kwargs) -> EpubArtifacts:
+ return convert_epub_file(path, out_dir, **kwargs)
diff --git a/graphify/llm.py b/graphify/llm.py
index 8c34cde6b..f5187668e 100644
--- a/graphify/llm.py
+++ b/graphify/llm.py
@@ -99,11 +99,13 @@ def _resolve_ollama_base_url(default: str) -> str:
BACKENDS: dict[str, dict] = {
"claude": {
+ # Recommended for best diagram / UI / chart understanding in 2026+.
+ # Use ANTHROPIC_MODEL=claude-opus-5 or latest sonnet for max quality.
# ANTHROPIC_BASE_URL points the backend at any Anthropic-compatible
# server (LiteLLM proxy, gateways, ...); ANTHROPIC_MODEL overrides the
# default model. Mirrors the OPENAI_BASE_URL / OPENAI_MODEL pattern.
"base_url": os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
- "default_model": os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6"),
+ "default_model": os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5"),
"env_key": "ANTHROPIC_API_KEY",
"pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens
"temperature": 0,
@@ -136,7 +138,7 @@ def _resolve_ollama_base_url(default: str) -> str:
# Gemini models (LiteLLM, self-hosted proxy, ...). Falls back to Google's
# official OpenAI-compatible endpoint.
"base_url": os.environ.get("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta/openai/"),
- "default_model": "gemini-3-flash-preview",
+ "default_model": "gemini-3.5-flash",
"env_keys": ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
"model_env_key": "GRAPHIFY_GEMINI_MODEL",
"pricing": {"input": 0.50, "output": 3.00}, # USD per 1M tokens
@@ -146,18 +148,19 @@ def _resolve_ollama_base_url(default: str) -> str:
"vision": True,
},
"openai": {
+ # For best vision results use GPT-5.6 / o-series or later models via OPENAI_MODEL.
# OPENAI_BASE_URL points the backend at any OpenAI-compatible server
# (llama.cpp, vLLM, LM Studio, ...); OPENAI_MODEL overrides the default
# model. GRAPHIFY_OPENAI_MODEL still wins over OPENAI_MODEL when both
# are set (via model_env_key).
"base_url": os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
- "default_model": os.environ.get("OPENAI_MODEL", "gpt-4.1-mini"),
+ "default_model": os.environ.get("OPENAI_MODEL", "gpt-5.6-mini"),
"env_key": "OPENAI_API_KEY",
"model_env_key": "GRAPHIFY_OPENAI_MODEL",
"max_tokens": 16384,
"pricing": {"input": 0.40, "output": 1.60}, # USD per 1M tokens
- # Default (gpt-4.1-mini) accepts temperature=0. Reasoning models
- # (o1/o3/o4/gpt-5) reject any explicit temperature and have it omitted
+ # Default (gpt-5.6-mini) accepts temperature=0. Reasoning models
+ # (o1/o3/o4/gpt-5 and later) reject any explicit temperature and have it omitted
# automatically by _resolve_temperature; GRAPHIFY_LLM_TEMPERATURE
# overrides either way (#1191).
"temperature": 0,
@@ -187,15 +190,15 @@ def _resolve_ollama_base_url(default: str) -> str:
# AZURE_OPENAI_DEPLOYMENT or GRAPHIFY_AZURE_MODEL (deployment name).
# base_url is intentionally absent — prevents accidental routing through
# _call_openai_compat, which requires it and uses the wrong SDK client class.
- "default_model": os.environ.get("AZURE_OPENAI_DEPLOYMENT", os.environ.get("GRAPHIFY_AZURE_MODEL", "gpt-4o")),
+ "default_model": os.environ.get("AZURE_OPENAI_DEPLOYMENT", os.environ.get("GRAPHIFY_AZURE_MODEL", "gpt-5.6")),
"env_key": "AZURE_OPENAI_API_KEY",
"model_env_key": "GRAPHIFY_AZURE_MODEL",
- "pricing": {"input": 2.50, "output": 10.00}, # USD per 1M tokens (gpt-4o; may mis-estimate other deployments)
+ "pricing": {"input": 2.50, "output": 10.00}, # USD per 1M tokens (use GRAPHIFY_AZURE_MODEL for modern deployment e.g. gpt-5.6 or claude via proxy)
"temperature": 0,
"max_tokens": 16384,
},
"bedrock": {
- "default_model": "anthropic.claude-3-5-sonnet-20241022-v2:0",
+ "default_model": "anthropic.claude-sonnet-5",
"model_env_key": "GRAPHIFY_BEDROCK_MODEL",
"pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens
"temperature": 0,
@@ -350,7 +353,7 @@ def _resolve_temperature(default: float | None, model: str = "") -> float | None
- a numeric value (e.g. "0", "0.2", "1") is used verbatim;
- the literal "none"/"omit"/"default" (case-insensitive) means
"omit the temperature parameter entirely" (-> None).
- 2. Otherwise, reasoning models (o1/o3/o4/gpt-5) get None — the parameter
+ 2. Otherwise, reasoning models (o1/o3/o4/gpt-5 and later) get None — the parameter
must be omitted or the API rejects the request.
3. Otherwise, the backend config default (`default`, usually 0).
@@ -733,23 +736,85 @@ def _bind_node_evidence(result: dict, text_units: "list[Path | FileSlice]", root
".gif": "image/gif",
".webp": "image/webp",
}
-# Per-image byte ceiling. Anthropic caps a request at 32 MB and Bedrock images
-# at ~5 MB; 5 MB per image keeps every backend within limits. Oversized images
-# fall back to a text reference (the node is still created, just unseen).
-_MAX_IMAGE_BYTES = 5 * 1024 * 1024
-# Flat token estimate per image for chunk packing. Vision models bill an image
-# at a roughly fixed cost regardless of file size, so estimating by byte size
-# (as the generic path does) would force every large PNG into its own chunk.
+# ── Vision image limits (now configurable + auto-tuned) ───────────────────────
+# Per-image byte ceiling for base64/inline backends.
+# Raised default for modern models (Claude 4+, GPT-4.1+, Gemini 3.5+ families
+# routinely handle 20-50+ MB images with good downsampling).
+# Oversized images still produce a graph node (as text reference).
+#
+# Configure with GRAPHIFY_MAX_IMAGE_MB (e.g. 50). Path-based backends
+# (claude-cli) ignore this and let the server downsample.
+_MAX_IMAGE_BYTES = 32 * 1024 * 1024
+
+
+def _get_max_image_bytes() -> int:
+ """Resolve per-image byte limit, preferring env override then modern default."""
+ raw = os.environ.get("GRAPHIFY_MAX_IMAGE_MB", "").strip()
+ if raw:
+ try:
+ mb = int(raw)
+ if mb > 0:
+ return mb * 1024 * 1024
+ except (ValueError, TypeError):
+ print(
+ f"[graphify] GRAPHIFY_MAX_IMAGE_MB={raw!r} is not a positive integer; "
+ f"using default {_MAX_IMAGE_BYTES // (1024*1024)}MB",
+ file=sys.stderr,
+ )
+ return _get_max_image_bytes()
+
+
+# Flat token estimate per image for chunk packing.
_IMAGE_TOKEN_ESTIMATE = 1_600
-# Hard cap on images per chunk, independent of the token budget. A large
-# token budget would otherwise pack hundreds of images into one request —
-# past provider per-request image limits (Anthropic allows 100), and far too
-# many for the claude-cli Read-tool loop to work through. Keeps memory and
-# request size bounded on image-dense corpora.
-_MAX_IMAGES_PER_CHUNK = 20
+
+
+def _get_max_images_per_chunk(backend: str | None = None, token_budget: int | None = None) -> int:
+ """Auto-compute a good max images per chunk.
+
+ The program tries to best-fit rather than forcing a conservative static cap.
+ Priority:
+ 1. GRAPHIFY_get_max_images_per_chunk() env var (explicit user override)
+ 2. Backend-aware sensible defaults for modern vision models
+ 3. Token-budget aware soft cap (don't waste most of the budget on images)
+ 4. Hard safety floor
+
+ claude-cli is kept lower because each image becomes a Read tool call.
+ High-capacity backends (current Claude/OpenAI/Gemini) support 40-100+.
+ """
+ # Explicit override wins (user says "don't leave it to the user unless necessary")
+ raw = os.environ.get("GRAPHIFY_get_max_images_per_chunk()", "").strip()
+ if raw:
+ try:
+ val = int(raw)
+ if val > 0:
+ return val
+ except (ValueError, TypeError):
+ print(f"[graphify] GRAPHIFY_get_max_images_per_chunk()={raw!r} ignored", file=sys.stderr)
+
+ # Backend-aware defaults (2026-era vision models)
+ backend = (backend or "").lower()
+ if backend == "claude-cli":
+ base = 15 # practical limit for repeated Read tool calls in one turn
+ elif backend in ("claude", "bedrock"):
+ base = 60 # modern Anthropic supports high image counts
+ elif backend in ("openai", "gemini", "azure", "kimi"):
+ base = 50
+ else:
+ base = 30
+
+ # Best-fit against token budget when available
+ if token_budget and token_budget > 0:
+ # Leave headroom for text + output tokens. Images are expensive but fixed-cost.
+ budget_headroom = max(8000, int(token_budget * 0.35))
+ from_budget = max(5, budget_headroom // _IMAGE_TOKEN_ESTIMATE)
+ base = min(base, from_budget)
+
+ return max(5, min(base, 100)) # absolute sanity bounds
+
+
# Backends that read an image by file path (claude-cli's Read tool)
# instead of inlining base64. They open the file themselves and downsample as
-# needed, so `_MAX_IMAGE_BYTES` does not apply and the bytes never need loading.
+# needed, so image byte limits do not apply and the bytes never need loading.
_PATH_IMAGE_BACKENDS = {"claude-cli"}
@@ -757,7 +822,7 @@ def _bind_node_evidence(result: dict, text_units: "list[Path | FileSlice]", root
class _ImageRef:
"""A single image destined for a vision request.
- `raw` is None when the image is unreadable or exceeds `_MAX_IMAGE_BYTES`, or
+ `raw` is None when the image is unreadable or exceeds the configured per-image limit (_get_max_image_bytes), or
when the target backend has no vision support — in every such case the
renderers emit a text reference instead of pixels, so the image still
becomes a graph node.
@@ -799,7 +864,7 @@ def _build_image_refs(image_files: list[Path], root: Path, *, read_bytes: bool =
"""Build `_ImageRef`s for raster images.
`read_bytes=True` (base64 backends) loads the pixels and drops any image over
- `_MAX_IMAGE_BYTES` to a reference, because a base64 request body has a hard
+ `_get_max_image_bytes()` to a reference, because a base64 request body has a hard
size ceiling. `read_bytes=False` (path-based backends — claude-cli)
skips the read entirely: those backends open the file themselves and
downsample as needed, so there is no per-image size limit and no reason to
@@ -1821,9 +1886,15 @@ def _estimate_file_tokens(unit: "Path | FileSlice") -> int:
def _pack_chunks_by_tokens(
files: "list[Path | FileSlice]",
token_budget: int,
+ *,
+ backend: str | None = None,
) -> "list[list[Path | FileSlice]]":
"""Greedily pack files/slices into chunks that fit a token budget.
+ Image count per chunk is auto-tuned (see _get_max_images_per_chunk).
+ """
+ """Greedily pack files/slices into chunks that fit a token budget.
+
Units are first grouped by parent directory so related artifacts share a
chunk (cross-file edges are more likely to be extracted within a chunk
than across chunks). Within each directory, units are added one at a
@@ -1849,7 +1920,7 @@ def _pack_chunks_by_tokens(
cost = _estimate_file_tokens(unit)
is_image = not isinstance(unit, FileSlice) and _is_vision_image(unit)
over_budget = current_tokens + cost > token_budget
- over_images = is_image and current_images >= _MAX_IMAGES_PER_CHUNK
+ over_images = is_image and current_images >= _get_max_images_per_chunk(backend=backend, token_budget=token_budget)
if current and (over_budget or over_images):
chunks.append(current)
current = []
@@ -2214,7 +2285,7 @@ def extract_corpus_parallel(
# silently dropped (#1369). Files at/under the cap pass through unchanged.
files = expand_oversized_files(files, _FILE_CHAR_CAP)
if token_budget is not None:
- chunks = _pack_chunks_by_tokens(files, token_budget=token_budget)
+ chunks = _pack_chunks_by_tokens(files, token_budget=token_budget, backend=backend)
else:
chunks = [files[i:i + chunk_size] for i in range(0, len(files), chunk_size)]
diff --git a/graphify/multigraph_compat.py b/graphify/multigraph_compat.py
index 7ac62e275..927012778 100644
--- a/graphify/multigraph_compat.py
+++ b/graphify/multigraph_compat.py
@@ -2,8 +2,8 @@
Verifies that the current NetworkX runtime supports the behaviors a future
opt-in --multigraph build will rely on. The probe is BEHAVIOR-based, not
-version-based — both NX 3.4.2 (Py 3.10 lane) and NX 3.6.1+ (Py 3.11+ lane)
-pass. The probe result is cached for the process lifetime via lru_cache.
+version-based.
+The probe result is cached for the process lifetime via lru_cache.
No call sites added yet; downstream multigraph PRs will gate on
require_multigraph_capabilities() before enabling MDG mode.
diff --git a/graphify/presentation.py b/graphify/presentation.py
new file mode 100644
index 000000000..d30e02997
--- /dev/null
+++ b/graphify/presentation.py
@@ -0,0 +1,1869 @@
+"""Bounded semantic extraction for untrusted PowerPoint ``.pptx`` packages.
+
+A presentation is converted into a content-versioned artifact bundle containing
+structured Markdown plus extracted images, audio/video, and embedded files. The
+module reads OOXML directly so it can recover relationships and media that
+``python-pptx`` does not expose, and it never executes or fetches package content.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import os
+import posixpath
+import re
+import shutil
+import unicodedata
+import uuid
+import zipfile
+from dataclasses import asdict, dataclass, fields
+from pathlib import Path, PurePosixPath
+from typing import TYPE_CHECKING, Iterable
+from urllib.parse import unquote, urlsplit
+
+from defusedxml.ElementTree import ParseError as _XmlParseError
+from defusedxml.ElementTree import fromstring as _safe_xml_fromstring
+from defusedxml.common import DefusedXmlException
+
+if TYPE_CHECKING:
+ from xml.etree.ElementTree import Element as ETElement
+else: # pragma: no cover - runtime alias for annotations
+ ETElement = object
+
+
+CONVERTER_VERSION = "2"
+MANIFEST_SCHEMA_VERSION = 2
+
+_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+_PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
+_CONTENT_TYPES_PART = "[Content_Types].xml"
+_ROOT_RELS_PART = "_rels/.rels"
+
+_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tif", ".tiff"}
+_SEMANTIC_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
+_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".ogg", ".aac", ".wma", ".flac"}
+_VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v", ".wmv", ".mpeg", ".mpg"}
+_MIME_EXTENSIONS = {
+ "image/png": ".png",
+ "image/jpeg": ".jpg",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/svg+xml": ".svg",
+ "image/bmp": ".bmp",
+ "image/tiff": ".tiff",
+ "audio/mpeg": ".mp3",
+ "audio/mp4": ".m4a",
+ "audio/wav": ".wav",
+ "audio/x-wav": ".wav",
+ "audio/ogg": ".ogg",
+ "video/mp4": ".mp4",
+ "video/quicktime": ".mov",
+ "video/webm": ".webm",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
+ "application/pdf": ".pdf",
+}
+
+
+class PresentationError(ValueError):
+ """Raised when a presentation is malformed, unsafe, or outside configured bounds."""
+
+
+@dataclass(frozen=True)
+class PresentationLimits:
+ """Resource policy for one PPTX conversion."""
+
+ max_raw_bytes: int = 50 * 1024 * 1024
+ max_members: int = 10_000
+ max_decompressed_bytes: int = 512 * 1024 * 1024
+ max_member_bytes: int = 64 * 1024 * 1024
+ max_xml_bytes: int = 8 * 1024 * 1024
+ max_xml_total_bytes: int = 64 * 1024 * 1024
+ max_compression_ratio: int = 200
+ max_slides: int = 1_000
+ max_relationships_per_part: int = 2_000
+ max_assets: int = 2_000
+ max_asset_bytes: int = 50 * 1024 * 1024
+ max_extracted_asset_bytes: int = 256 * 1024 * 1024
+ max_text_chars: int = 20_000
+ max_markdown_chars: int = 2_000_000
+ max_external_target_chars: int = 4_096
+ max_xml_elements: int = 250_000
+ max_xml_depth: int = 64
+ max_manifest_bytes: int = 2_000_000
+
+ def as_policy(self) -> dict[str, int]:
+ """Stable policy dict used for cache identity and verification."""
+ return {field.name: int(getattr(self, field.name)) for field in fields(self)}
+
+
+@dataclass(frozen=True)
+class PresentationArtifacts:
+ markdown_path: Path
+ manifest_path: Path
+ images: tuple[Path, ...]
+ media: tuple[Path, ...]
+ attachments: tuple[Path, ...]
+ warnings: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class _Relationship:
+ relationship_id: str
+ relationship_type: str
+ target: str
+ external: bool
+ resolved_target: str | None
+
+ @property
+ def kind(self) -> str:
+ return self.relationship_type.rstrip("/").rsplit("/", 1)[-1].lower()
+
+
+@dataclass
+class _Asset:
+ kind: str
+ role: str
+ slide: int
+ relationship_id: str
+ source_part: str
+ content_type: str
+ filename: str
+ payload: bytes
+ description: str = ""
+
+ @property
+ def sha256(self) -> str:
+ return hashlib.sha256(self.payload).hexdigest()
+
+ @property
+ def size(self) -> int:
+ return len(self.payload)
+
+
+@dataclass
+class _Extraction:
+ markdown: str
+ assets: list[_Asset]
+ slides: list[dict]
+ warnings: list[str]
+ presentation_part: str
+
+
+class _Package:
+ def __init__(self, raw: bytes, limits: PresentationLimits):
+ self.raw = raw
+ self.limits = limits
+ self.archive = zipfile.ZipFile(io.BytesIO(raw))
+ self.infos: dict[str, zipfile.ZipInfo] = {}
+ self.xml_bytes_read = 0
+ self.asset_bytes_read = 0
+ self._validate_archive()
+ self.content_types = self._load_content_types()
+
+ def close(self) -> None:
+ self.archive.close()
+
+ def _validate_archive(self) -> None:
+ infos = self.archive.infolist()
+ if len(infos) > self.limits.max_members:
+ raise PresentationError(
+ f"PPTX has {len(infos)} archive members; limit is {self.limits.max_members} members"
+ )
+ declared_total = 0
+ compressed_total = 0
+ for info in infos:
+ name = info.filename
+ if not _safe_member_name(name):
+ raise PresentationError(f"unsafe PPTX archive member name: {name!r}")
+ if name in self.infos:
+ raise PresentationError(f"duplicate PPTX archive member: {name!r}")
+ if info.flag_bits & 0x1:
+ raise PresentationError(f"encrypted PPTX archive member is not supported: {name!r}")
+ if info.file_size > self.limits.max_member_bytes:
+ raise PresentationError(
+ f"PPTX member {name!r} declares {info.file_size} bytes; "
+ f"per-member limit is {self.limits.max_member_bytes}"
+ )
+ declared_total += info.file_size
+ compressed_total += info.compress_size
+ self.infos[name] = info
+ if declared_total > self.limits.max_decompressed_bytes:
+ raise PresentationError(
+ f"PPTX declares {declared_total} decompressed bytes; "
+ f"limit is {self.limits.max_decompressed_bytes}"
+ )
+ if (
+ declared_total
+ and declared_total / max(compressed_total, 1) > self.limits.max_compression_ratio
+ ):
+ raise PresentationError("PPTX compression ratio exceeds the configured safety limit")
+ if _CONTENT_TYPES_PART not in self.infos or _ROOT_RELS_PART not in self.infos:
+ raise PresentationError("not a PPTX package: required presentation parts are missing")
+
+ def read_member(self, name: str, *, cap: int | None = None, asset: bool = False) -> bytes:
+ info = self.infos.get(name)
+ if info is None:
+ raise PresentationError(f"PPTX relationship target is missing: {name}")
+ ceiling = min(cap or self.limits.max_member_bytes, self.limits.max_member_bytes)
+ chunks: list[bytes] = []
+ total = 0
+ try:
+ with self.archive.open(info) as member:
+ while True:
+ chunk = member.read(min(1024 * 1024, ceiling + 1 - total))
+ if not chunk:
+ break
+ total += len(chunk)
+ if total > ceiling:
+ raise PresentationError(
+ f"PPTX member {name!r} exceeds the {ceiling}-byte extraction limit"
+ )
+ chunks.append(chunk)
+ except (zipfile.BadZipFile, EOFError, RuntimeError, OSError) as exc:
+ raise PresentationError(f"could not read PPTX member {name!r}: {exc}") from exc
+ if asset:
+ self.asset_bytes_read += total
+ if self.asset_bytes_read > self.limits.max_extracted_asset_bytes:
+ raise PresentationError(
+ "PPTX extracted assets exceed the configured total byte limit"
+ )
+ return b"".join(chunks)
+
+ def parse_xml(self, name: str) -> ETElement:
+ data = self.read_member(name, cap=self.limits.max_xml_bytes)
+ self.xml_bytes_read += len(data)
+ if self.xml_bytes_read > self.limits.max_xml_total_bytes:
+ raise PresentationError("PPTX XML parts exceed the configured total byte limit")
+ # Reject DTDs/entities before parse. Byte-pattern screening alone is not
+ # enough: UTF-16 / UTF-32 payloads hide ASCII markers. defusedxml forbids
+ # entities and external resolution for every encoding it can decode.
+ try:
+ root = _safe_xml_fromstring(
+ data,
+ forbid_dtd=True,
+ forbid_entities=True,
+ forbid_external=True,
+ )
+ except DefusedXmlException as exc:
+ raise PresentationError(
+ f"DTD/entity declarations are forbidden in PPTX XML: {name}"
+ ) from exc
+ except _XmlParseError as exc:
+ raise PresentationError(f"malformed PPTX XML in {name}: {exc}") from exc
+ except (LookupError, UnicodeError, ValueError, TypeError) as exc:
+ raise PresentationError(f"unreadable PPTX XML encoding in {name}: {exc}") from exc
+ _enforce_xml_bounds(root, name, self.limits)
+ return root
+
+ def _load_content_types(self) -> tuple[dict[str, str], dict[str, str]]:
+ root = self.parse_xml(_CONTENT_TYPES_PART)
+ defaults: dict[str, str] = {}
+ overrides: dict[str, str] = {}
+ for element in root:
+ local = _local(element.tag)
+ if local == "Default":
+ defaults[element.attrib.get("Extension", "").lower()] = element.attrib.get(
+ "ContentType", "application/octet-stream"
+ )
+ elif local == "Override":
+ name = element.attrib.get("PartName", "").lstrip("/")
+ if name:
+ overrides[name] = element.attrib.get("ContentType", "application/octet-stream")
+ return defaults, overrides
+
+ def content_type(self, part: str) -> str:
+ defaults, overrides = self.content_types
+ if part in overrides:
+ return overrides[part]
+ extension = PurePosixPath(part).suffix.lstrip(".").lower()
+ return defaults.get(extension, "application/octet-stream")
+
+
+class _Markdown:
+ def __init__(self, limit: int):
+ self.limit = limit
+ self.parts: list[str] = []
+ self.size = 0
+ self.truncated = False
+
+ def add(self, block: str) -> None:
+ block = block.strip("\n")
+ if not block or self.truncated:
+ return
+ rendered = block + "\n\n"
+ if self.size + len(rendered) <= self.limit:
+ self.parts.append(rendered)
+ self.size += len(rendered)
+ return
+ notice = "\n\n> [!WARNING]\n> Presentation Markdown truncated at a safe section boundary.\n"
+ room = self.limit - self.size
+ if room >= len(notice):
+ self.parts.append(notice)
+ self.size += len(notice)
+ self.truncated = True
+
+ def render(self) -> str:
+ return "".join(self.parts).rstrip() + "\n"
+
+
+def _local(tag: str) -> str:
+ return tag.rsplit("}", 1)[-1]
+
+
+def _safe_member_name(name: str) -> bool:
+ if not name or "\x00" in name or "\\" in name or name.startswith("/"):
+ return False
+ path = PurePosixPath(name)
+ return not path.is_absolute() and all(part not in {"", ".", ".."} for part in path.parts)
+
+
+def _read_source(path: Path, limit: int) -> bytes:
+ try:
+ with path.open("rb") as source:
+ chunks: list[bytes] = []
+ total = 0
+ while True:
+ chunk = source.read(min(1024 * 1024, limit + 1 - total))
+ if not chunk:
+ break
+ total += len(chunk)
+ if total > limit:
+ raise PresentationError(
+ f"PPTX source exceeds the configured {limit}-byte raw input limit"
+ )
+ chunks.append(chunk)
+ except PresentationError:
+ raise
+ except OSError as exc:
+ raise PresentationError(f"could not read PPTX source {path}: {exc}") from exc
+ return b"".join(chunks)
+
+
+def _rels_part(part: str) -> str:
+ directory, name = posixpath.split(part)
+ return posixpath.join(directory, "_rels", name + ".rels")
+
+
+def _resolve_target(source_part: str, target: str) -> str | None:
+ decoded = unquote(target)
+ parsed = urlsplit(decoded)
+ if parsed.scheme or parsed.netloc:
+ return None
+ decoded = parsed.path
+ if not decoded or "\x00" in decoded or "\\" in decoded or decoded.startswith("/"):
+ return None
+ resolved = posixpath.normpath(posixpath.join(posixpath.dirname(source_part), decoded))
+ if resolved == ".." or resolved.startswith("../") or not _safe_member_name(resolved):
+ return None
+ return resolved
+
+
+def _relationships(package: _Package, part: str) -> dict[str, _Relationship]:
+ rels_name = _rels_part(part)
+ if rels_name not in package.infos:
+ return {}
+ root = package.parse_xml(rels_name)
+ children = [element for element in root if _local(element.tag) == "Relationship"]
+ if len(children) > package.limits.max_relationships_per_part:
+ raise PresentationError(
+ f"PPTX part {part!r} exceeds the relationship limit of "
+ f"{package.limits.max_relationships_per_part}"
+ )
+ relationships: dict[str, _Relationship] = {}
+ for element in children:
+ relationship_id = element.attrib.get("Id", "")
+ relationship_type = element.attrib.get("Type", "")
+ target = element.attrib.get("Target", "")
+ if not relationship_id or relationship_id in relationships:
+ raise PresentationError(f"missing or duplicate relationship ID in {_rels_part(part)}")
+ external = element.attrib.get("TargetMode", "").lower() == "external"
+ resolved = None if external else _resolve_target(part, target)
+ relationships[relationship_id] = _Relationship(
+ relationship_id, relationship_type, target, external, resolved
+ )
+ return relationships
+
+
+def _relationship_attr(element: ETElement, local_name: str) -> str | None:
+ exact = f"{{{_REL_NS}}}{local_name}"
+ if exact in element.attrib:
+ return element.attrib[exact]
+ for key, value in element.attrib.items():
+ if _local(key) == local_name and key.startswith("{"):
+ return value
+ return None
+
+
+def _first(element: ETElement, local_name: str) -> ETElement | None:
+ return next((item for item in element.iter() if _local(item.tag) == local_name), None)
+
+
+def _children(element: ETElement, local_name: str) -> list[ETElement]:
+ return [item for item in element if _local(item.tag) == local_name]
+
+
+def _clean_text(value: str | None, limit: int) -> str:
+ if not value:
+ return ""
+ value = value.replace("\x00", "�").replace("\r\n", "\n").replace("\r", "\n")
+ value = "".join(char for char in value if char in "\n\t" or ord(char) >= 32)
+ value = re.sub(r"[ \t]+", " ", value).strip()
+ if len(value) > limit:
+ return value[: max(0, limit - 15)] + "… [truncated]"
+ return value
+
+
+def _all_text(element: ETElement, limit: int) -> str:
+ values = [item.text or "" for item in element.iter() if _local(item.tag) in {"t", "text"}]
+ return _clean_text(" ".join(value for value in values if value), limit)
+
+
+def _paragraphs(element: ETElement, limit: int) -> list[tuple[int, str, list[str]]]:
+ paragraphs: list[tuple[int, str, list[str]]] = []
+ for paragraph in (item for item in element.iter() if _local(item.tag) == "p"):
+ pieces: list[str] = []
+ links: list[str] = []
+ for item in paragraph.iter():
+ local = _local(item.tag)
+ if local == "t" and item.text:
+ pieces.append(item.text)
+ elif local == "br":
+ pieces.append("\n")
+ elif local in {"hlinkClick", "hlinkMouseOver"}:
+ relationship_id = _relationship_attr(item, "id")
+ if relationship_id:
+ links.append(relationship_id)
+ text = _clean_text("".join(pieces), limit)
+ if not text:
+ continue
+ properties = next(
+ (item for item in paragraph if _local(item.tag) in {"pPr", "endParaRPr"}), None
+ )
+ try:
+ level = int(properties.attrib.get("lvl", "0")) if properties is not None else 0
+ except ValueError:
+ level = 0
+ paragraphs.append((max(0, min(level, 8)), text, links))
+ return paragraphs
+
+
+def _shape_metadata(shape: ETElement, limit: int) -> dict[str, str]:
+ properties = _first(shape, "cNvPr")
+ result = {"id": "", "name": "", "description": "", "title": "", "placeholder": ""}
+ if properties is not None:
+ result.update(
+ {
+ "id": properties.attrib.get("id", ""),
+ "name": _clean_text(properties.attrib.get("name"), limit),
+ "description": _clean_text(properties.attrib.get("descr"), limit),
+ "title": _clean_text(properties.attrib.get("title"), limit),
+ }
+ )
+ placeholder = _first(shape, "ph")
+ if placeholder is not None:
+ result["placeholder"] = placeholder.attrib.get("type", "body")
+ transform = _first(shape, "xfrm")
+ if transform is not None:
+ offset = next((item for item in transform if _local(item.tag) == "off"), None)
+ extent = next((item for item in transform if _local(item.tag) == "ext"), None)
+ if offset is not None:
+ result["x"] = offset.attrib.get("x", "")
+ result["y"] = offset.attrib.get("y", "")
+ if extent is not None:
+ result["width"] = extent.attrib.get("cx", "")
+ result["height"] = extent.attrib.get("cy", "")
+ return result
+
+
+def _walk_shapes(tree: ETElement) -> Iterable[ETElement]:
+ shape_tags = {"sp", "pic", "graphicFrame", "cxnSp", "grpSp", "oleObj", "contentPart"}
+ for child in tree:
+ local = _local(child.tag)
+ if local not in shape_tags:
+ continue
+ yield child
+ if local == "grpSp":
+ yield from _walk_shapes(child)
+
+
+def _used_relationship_ids(element: ETElement, known: set[str]) -> set[str]:
+ used: set[str] = set()
+ for item in element.iter():
+ for value in item.attrib.values():
+ if value in known:
+ used.add(value)
+ return used
+
+
+def _markdown_table(rows: list[list[str]]) -> str:
+ if not rows:
+ return ""
+ width = max(len(row) for row in rows)
+ normalized = [row + [""] * (width - len(row)) for row in rows]
+
+ def escape(value: str) -> str:
+ return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
+
+ lines = ["| " + " | ".join(escape(cell) for cell in normalized[0]) + " |"]
+ lines.append("| " + " | ".join("---" for _ in range(width)) + " |")
+ lines.extend("| " + " | ".join(escape(cell) for cell in row) + " |" for row in normalized[1:])
+ return "\n".join(lines)
+
+
+def _chart_values(container: ETElement | None, limit: int) -> list[str]:
+ if container is None:
+ return []
+ points: list[tuple[int, str]] = []
+ for point in (item for item in container.iter() if _local(item.tag) == "pt"):
+ value_element = next((item for item in point if _local(item.tag) in {"v", "f"}), None)
+ if value_element is None:
+ value_element = _first(point, "v")
+ value = _clean_text(value_element.text if value_element is not None else "", limit)
+ try:
+ index = int(point.attrib.get("idx", len(points)))
+ except ValueError:
+ index = len(points)
+ points.append((index, value))
+ if points:
+ return [value for _, value in sorted(points)]
+ return [
+ _clean_text(item.text, limit)
+ for item in container.iter()
+ if _local(item.tag) == "v" and _clean_text(item.text, limit)
+ ]
+
+
+def _extract_chart(
+ package: _Package, part: str, limits: PresentationLimits
+) -> tuple[str, list[str]]:
+ root = package.parse_xml(part)
+ lines: list[str] = []
+ title = next((item for item in root.iter() if _local(item.tag) == "title"), None)
+ title_text = _all_text(title, limits.max_text_chars) if title is not None else ""
+ if title_text:
+ lines.append(f"**Chart title:** {title_text}")
+ series_elements = [item for item in root.iter() if _local(item.tag) in {"ser", "series"}]
+ for series_index, series in enumerate(series_elements, start=1):
+ tx = next((item for item in series if _local(item.tag) == "tx"), None)
+ name = _all_text(tx, limits.max_text_chars) if tx is not None else ""
+ if not name and tx is not None:
+ name = next(
+ (
+ _clean_text(item.text, limits.max_text_chars)
+ for item in tx.iter()
+ if _local(item.tag) == "v" and item.text
+ ),
+ "",
+ )
+ name = name or f"Series {series_index}"
+ category = next((item for item in series if _local(item.tag) in {"cat", "xVal"}), None)
+ values = next(
+ (item for item in series if _local(item.tag) in {"val", "yVal", "bubbleSize"}), None
+ )
+ categories = _chart_values(category, limits.max_text_chars)
+ numeric = _chart_values(values, limits.max_text_chars)
+ rows = [["Category", name]]
+ length = max(len(categories), len(numeric))
+ for index in range(length):
+ rows.append(
+ [
+ categories[index] if index < len(categories) else str(index + 1),
+ numeric[index] if index < len(numeric) else "",
+ ]
+ )
+ lines.append(f"**Series:** {name}")
+ if length:
+ lines.append(_markdown_table(rows))
+ if series_elements and all(_local(item.tag) == "series" for item in series_elements):
+ # Office 2016+ extended charts can keep dimensions in a shared cx:data
+ # area referenced by dataId rather than nesting cat/val under each series.
+ # Preserve the bounded cached evidence even when a series-to-dimension
+ # join is unavailable.
+ cached_values: list[str] = []
+ for element in root.iter():
+ if _local(element.tag) not in {"v", "t"} or not element.text:
+ continue
+ value = _clean_text(element.text, limits.max_text_chars)
+ if value and value not in cached_values:
+ cached_values.append(value)
+ if len(cached_values) >= 1_000:
+ break
+ if cached_values:
+ lines.append(
+ "**Extended-chart cached values:**\n"
+ + "\n".join(f"- {value}" for value in cached_values)
+ )
+ attachment_parts: list[str] = []
+ for relationship in _relationships(package, part).values():
+ if relationship.kind in {"package", "oleobject"} and relationship.resolved_target:
+ attachment_parts.append(relationship.resolved_target)
+ return "\n\n".join(lines), attachment_parts
+
+
+def _extract_diagram(package: _Package, part: str, limits: PresentationLimits) -> str:
+ root = package.parse_xml(part)
+ labels: dict[str, str] = {}
+ for point in (item for item in root.iter() if _local(item.tag) == "pt"):
+ model_id = point.attrib.get("modelId", "")
+ text = _all_text(point, limits.max_text_chars)
+ if model_id and text:
+ labels[model_id] = text
+ lines = [f"- Node `{model_id}`: {label}" for model_id, label in labels.items()]
+ for connection in (item for item in root.iter() if _local(item.tag) == "cxn"):
+ source = connection.attrib.get("srcId", "")
+ target = connection.attrib.get("destId", "")
+ if not source or not target:
+ continue
+ relation = connection.attrib.get("type", "connection")
+ lines.append(f"- {labels.get(source, source)} -> {labels.get(target, target)} ({relation})")
+ return "\n".join(lines)
+
+
+def _core_properties(
+ package: _Package, limits: PresentationLimits, core_part: str | None
+) -> dict[str, str]:
+ if not core_part or core_part not in package.infos:
+ return {}
+ root = package.parse_xml(core_part)
+ allowed = {
+ "title",
+ "subject",
+ "creator",
+ "keywords",
+ "description",
+ "lastModifiedBy",
+ "created",
+ "modified",
+ "category",
+ "contentStatus",
+ }
+ properties: dict[str, str] = {}
+ for element in root.iter():
+ local = _local(element.tag)
+ if local in allowed and element.text:
+ properties[local] = _clean_text(element.text, limits.max_text_chars)
+ return properties
+
+
+def _comment_authors(
+ package: _Package,
+ limits: PresentationLimits,
+ presentation_relationships: dict[str, _Relationship],
+) -> dict[str, str]:
+ authors: dict[str, str] = {}
+ relationships = [
+ relationship
+ for relationship in presentation_relationships.values()
+ if relationship.kind in {"commentauthors", "person", "persons"}
+ and not relationship.external
+ and relationship.resolved_target in package.infos
+ ]
+ for relationship in relationships:
+ assert relationship.resolved_target is not None
+ root = package.parse_xml(relationship.resolved_target)
+ for element in root.iter():
+ if _local(element.tag) not in {"cmAuthor", "person"}:
+ continue
+ identifier = (
+ element.attrib.get("id")
+ or element.attrib.get("authorId")
+ or element.attrib.get("userId")
+ )
+ name = (
+ element.attrib.get("name")
+ or element.attrib.get("displayName")
+ or element.attrib.get("initials")
+ )
+ if identifier and name:
+ authors[identifier] = _clean_text(name, limits.max_text_chars)
+ return authors
+
+
+def _safe_extension(part: str, content_type: str, kind: str) -> str:
+ suffix = PurePosixPath(part).suffix.lower()
+ if not re.fullmatch(r"\.[a-z0-9]{1,10}", suffix):
+ suffix = ""
+ mapped = _MIME_EXTENSIONS.get(content_type.lower(), "")
+ if kind == "image" and suffix not in _IMAGE_EXTENSIONS:
+ suffix = mapped if mapped in _IMAGE_EXTENSIONS else ".bin"
+ elif kind == "media" and suffix not in _AUDIO_EXTENSIONS | _VIDEO_EXTENSIONS:
+ suffix = mapped if mapped in _AUDIO_EXTENSIONS | _VIDEO_EXTENSIONS else ".bin"
+ elif kind == "attachment" and not suffix:
+ suffix = mapped or ".bin"
+ return suffix or ".bin"
+
+
+def _valid_semantic_image(payload: bytes, extension: str) -> bool:
+ if extension == ".png":
+ return payload.startswith(b"\x89PNG\r\n\x1a\n")
+ if extension in {".jpg", ".jpeg"}:
+ return payload.startswith(b"\xff\xd8\xff")
+ if extension == ".gif":
+ return payload.startswith((b"GIF87a", b"GIF89a"))
+ if extension == ".webp":
+ return len(payload) >= 12 and payload[:4] == b"RIFF" and payload[8:12] == b"WEBP"
+ if extension == ".svg":
+ prefix = payload[:4096].lstrip(b"\xef\xbb\xbf\t\r\n ").lower()
+ return b"