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:
// 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<String, String> = slide_paths
.iter()
.enumerate()
.map(|(i, p)| (p.clone(), format!("slide-{}", i + 1)))
.collect();
line 166:
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:
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:
-
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.
-
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.
-
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.
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.blocksis one flat sequence with nothing marking theslide boundaries.
The information is already computed inside the PPTX reader — every slide gets a
slide-Nanchor id — but the anchor node is only emitted for slides that someinternal 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
headingas 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):
The mechanism already exists
src/formats/pptx/mod.rs(v0.1.9), lines 96–103:line 166:
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:
Observed on 0.1.9 (and 0.1.7):
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:
Emit the slide anchor unconditionally. Drop the
targeted.contains(...)guard so every slide begins with
Inline::Anchor("slide-N"). Consumers thatignore anchors are unaffected; the markdown renderer already emits nothing
visible for a bare anchor. This matches the intent stated in the comment.
Add an explicit boundary to the model, e.g. a
Blockkind such aspage_break, or an optionalpage: int | NoneonBlock. More expressiveand self-documenting than an anchor, and would extend naturally to other
paginated formats.
Make it opt-in, if unconditional anchors are considered noise — a flag on
to_document(), or a separateto_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
.pptreader (src/formats/ppt/mod.rs) also walks slides inpresentation 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_partis not a substitute: for PPTX it names the media part(
ppt/media/image1.png), not the slide the image sits on, and text-onlyslides have no asset at all.
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.