Skip to content

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

Description

@dksrudwns

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:

  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions