Expose slide boundaries for PPTX/PPT (the anchor already exists, it is just gated)
- Dominant language
- Rust
- Stars
- 21.5k
- Forks
- 1.3k
- Avg merge
- 42m
- Merged PRs (30d)
- 17
Description
# Expose slide boundaries for PPTX/PPT (the anchor already exists, it is just gated)
## Summary
`to_document()` gives no way to tell where one slide ends and the next begins.
For a deck, `Document.blocks` is one flat sequence with nothing marking the
slide boundaries.
The information is already computed inside the PPTX reader — every slide gets a
`slide-N` anchor id — but the anchor node is only emitted for slides that some
internal hyperlink happens to target. Decks without slide-to-slide links (the
common case) get nothing.
Making that anchor unconditional, or exposing the slide index some other way,
would be a small change with a large payoff for downstream consumers.
## Why this matters
RAG / document-indexing pipelines chunk presentations **per slide**, so that a
retrieved passage can cite "slide 7". Without a boundary signal, a deck can only
be indexed as one undivided blob, or the boundary has to be guessed.
The obvious heuristic — treat each `heading` as a slide start — does not hold.
Slides that use plain text boxes instead of the title placeholder, and
table-only or image-only slides, produce no heading at all, so they get merged
into the preceding slide.
Measured on a 5-slide deck (titled / untitled-with-two-textboxes /
table-only / titled / title-only):
```
anydoc blocks: heading, paragraph, paragraph, paragraph, table, heading, paragraph, heading
headings at: [1, 6, 8] -> 3 slides inferred, actual 5
```
## The mechanism already exists
`src/formats/pptx/mod.rs` (v0.1.9), lines 96–103:
```rust
// Every slide has a start anchor id so internal slide-to-slide links
// resolve after concatenation; the anchor node is emitted only on
// slides some link actually targets.
let slide_anchors: HashMap = slide_paths
.iter()
.enumerate()
.map(|(i, p)| (p.clone(), format!("slide-{}", i + 1)))
.collect();
```
line 166:
```rust
if targeted.contains(slide_path)
&& let Some(anchor) = slide_anchors.get(slide_path)
{
blocks.push(Block::Paragraph(vec![Inline::Anchor(anchor.clone())]));
}
```
The comment says every slide *has* a start anchor id; only the emission is
narrowed to link targets.
## Reproduction
Same three-slide deck, built twice — once with an internal slide-to-slide
hyperlink, once without:
```python
from pptx import Presentation
from pptx.util import Inches
import anydoc
def build(with_link, out):
prs = Presentation()
blank = prs.slide_layouts[6]
slides = []
for i in (1, 2, 3):
s = prs.slides.add_slide(blank)
tb = s.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1))
tb.text_frame.text = f"SLIDE{i} body"
slides.append((s, tb))
if with_link:
slides[0][1].click_action.target_slide = slides[2][0] # slide 1 -> slide 3
prs.save(out)
return out
def anchors(path):
doc = anydoc.to_document(open(path, "rb").read())
return [i.anchor for b in doc.blocks for i in (b.content or []) if i.kind == "anchor"]
print(anchors(build(False, "no_link.pptx"))) # []
print(anchors(build(True, "with_link.pptx"))) # ['slide-3']
```
Observed on 0.1.9 (and 0.1.7):
```
internal slide link absent: 3 blocks, anchors []
internal slide link present: 4 blocks, anchors ['slide-3']
```
So the anchor is produced correctly — it is emitted for slide 3 only because a
link points at it. Slides 1 and 2 have anchor ids that never reach the model.
## Possible approaches
Roughly in order of how small the change looks from the outside:
1. **Emit the slide anchor unconditionally.** Drop the `targeted.contains(...)`
guard so every slide begins with `Inline::Anchor("slide-N")`. Consumers that
ignore anchors are unaffected; the markdown renderer already emits nothing
visible for a bare anchor. This matches the intent stated in the comment.
2. **Add an explicit boundary to the model**, e.g. a `Block` kind such as
`page_break`, or an optional `page: int | None` on `Block`. More expressive
and self-documenting than an anchor, and would extend naturally to other
paginated formats.
3. **Make it opt-in**, if unconditional anchors are considered noise — a flag on
`to_document()`, or a separate `to_document_paginated()`.
Option 1 alone would be enough for our use case, and appears to be the smallest
change. Happy to open a PR for whichever direction you prefer.
## Notes
- The legacy `.ppt` reader (`src/formats/ppt/mod.rs`) also walks slides in
presentation order (`walk_slide_list`, "Walk slides in presentation order"),
so the same boundary information exists there. It would be good if both
formats reported boundaries the same way.
- `Asset.origin_part` is not a substitute: for PPTX it names the media part
(`ppt/media/image1.png`), not the slide the image sits on, and text-only
slides have no asset at all.
- The current workaround is to split the deck into single-slide files and parse
each one. That is accurate but costs ~9x the time of the alternative parser we
are comparing against, most of it spent rewriting the package per slide.
Parsing the whole deck in one call is ~30-50x faster than that alternative, so
the boundary signal is the only thing standing in the way.
Environment: firecrawl-anydoc 0.1.7 and 0.1.9, Python 3.11, Windows and Linux.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in src/formats/pptx/mod.rs, especially the slide_anchors construction around lines 96–103 and the targeted guard around line 166; run the provided Python reproduction to compare decks with and without internal links. Check src/formats/ppt/mod.rs and its walk_slide_list entry point for equivalent behavior. Done means slide boundaries are exposed consistently for PPTX/PPT without requiring internal hyperlinks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, rust
- Domain
- backend, content
- Issue type
- Feature
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100