firecrawl / firecrawl/anydoc

Expose slide boundaries for PPTX/PPT (the anchor already exists, it is just gated)

Open
#94 1 comment 1 reaction 0 assignees View on GitHub
enhancement P2
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.