Skip to main content

pptx_edit/
target.rs

1//! Versioned story projections and the target resolver shared by reads, searches and edit
2//! batches.
3//!
4//! A story projects to its paragraphs' text joined by one `\n` with no trailing separator, so
5//! offsets are the story's own UTF-16 positions: each paragraph mark is a separator's unit and
6//! the final mark is never addressable. Soft line breaks read as `\n` inside a paragraph; the
7//! paragraph records tell them apart from separators.
8
9use pptx_parse::{GraphicFrameData, PptxPackage, ShapeNode, TextBody, TextParagraph};
10use serde::{Deserialize, Serialize};
11
12use crate::batch::{
13    ByteBudget, DocumentVersion, EditFailure, EditFailureCode, EditRefusal, EditTarget,
14    MAX_REQUEST_BYTES, SlideTarget, failure, quoted, refusal, request_limit,
15};
16use crate::paragraph::{
17    ListCounters, ParagraphCascade, SlideParents, effective_list, is_title_placeholder,
18    shape_cascade, slide_hidden,
19};
20use crate::search::Needle;
21use crate::story::selects_paragraph;
22use crate::{
23    DeckSession, DeckSnapshot, EditResult, ShapeKind, ShapeSnapshot, SlideLayoutSnapshot,
24    SlideSnapshot, StorySnapshot,
25};
26
27/// Deepest shape nesting a target may address.
28const MAX_OWNERSHIP_DEPTH: usize = 32;
29/// JSON bytes one read or search response may hold, slide snapshots included.
30const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
31/// Matches a search returns when the request names no limit.
32const FIND_DEFAULT_LIMIT: u32 = 100;
33/// Matches one search may return.
34const FIND_MAX_LIMIT: u32 = 10_000;
35
36/// A shape addressed through the slide that owns it.
37#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "camelCase", deny_unknown_fields)]
39pub struct ShapeTarget {
40    pub slide_id: String,
41    pub shape_id: String,
42}
43
44/// A text story addressed through its slide and owning shape.
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46#[serde(rename_all = "camelCase", deny_unknown_fields)]
47pub struct StoryTarget {
48    pub slide_id: String,
49    pub shape_id: String,
50    pub story_id: String,
51}
52
53/// A half-open range of story-local UTF-16 offsets.
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct TextRange {
57    pub slide_id: String,
58    pub shape_id: String,
59    pub story_id: String,
60    pub start: u32,
61    pub end: u32,
62}
63
64impl TextRange {
65    fn story(&self) -> StoryTarget {
66        StoryTarget {
67            slide_id: self.slide_id.clone(),
68            shape_id: self.shape_id.clone(),
69            story_id: self.story_id.clone(),
70        }
71    }
72}
73
74/// The text a step addresses.
75#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(
77    tag = "kind",
78    rename_all = "camelCase",
79    rename_all_fields = "camelCase",
80    deny_unknown_fields
81)]
82pub enum TextTarget {
83    Range(TextRange),
84    /// The one exact, case-sensitive, paragraph-local occurrence of `text` in the story.
85    Search {
86        within: StoryTarget,
87        text: String,
88    },
89}
90
91#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct ReadRequest {
94    /// Restricts the read to these slides, returned in deck order; every slide when omitted.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub slide_ids: Option<Vec<String>>,
97}
98
99#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct ReadResponse {
102    pub version: DocumentVersion,
103    pub slides: Vec<SlideSnapshot>,
104    /// Every story of the read slides, in document order.
105    pub stories: Vec<StoryText>,
106}
107
108#[cfg(any(feature = "wasm", test))]
109#[derive(Serialize)]
110#[serde(rename_all = "camelCase")]
111pub(crate) struct CommandReadResponse {
112    #[serde(flatten)]
113    read: ReadResponse,
114    sources: Vec<CommandStorySource>,
115}
116
117#[cfg(any(feature = "wasm", test))]
118#[derive(Serialize)]
119#[serde(rename_all = "camelCase")]
120struct CommandStorySource {
121    story_id: String,
122    paragraphs: Vec<CommandParagraphSource>,
123}
124
125#[cfg(any(feature = "wasm", test))]
126#[derive(Serialize)]
127#[serde(rename_all = "camelCase")]
128struct CommandParagraphSource {
129    paragraph_id: String,
130    text: String,
131    fields: Vec<TextField>,
132    editable: bool,
133}
134
135/// One story's projected text.
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137#[serde(rename_all = "camelCase")]
138pub struct StoryText {
139    pub slide_id: String,
140    pub shape_id: String,
141    pub story_id: String,
142    /// Paragraph texts joined by `\n`.
143    pub text: String,
144    pub paragraphs: Vec<ParagraphText>,
145}
146
147/// One paragraph's place in its story's projected text.
148#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
149#[serde(rename_all = "camelCase")]
150pub struct ParagraphText {
151    #[serde(default)]
152    pub list: Option<crate::structured::ExportList>,
153    pub paragraph_id: String,
154    /// Story offsets of the paragraph's text; a following separator sits at `end`.
155    pub start: u32,
156    pub end: u32,
157    /// Story offsets of soft line breaks, which read as `\n` inside the paragraph.
158    pub line_breaks: Vec<u32>,
159    /// Field results, such as slide numbers, that text steps leave whole.
160    pub fields: Vec<TextField>,
161    /// False when saving could not keep a field once the paragraph changes: the field is empty
162    /// or left the unchanged leading or trailing text. Steps changing the paragraph refuse it.
163    pub editable: bool,
164}
165
166/// The story offsets of one field result.
167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
168#[serde(rename_all = "camelCase")]
169pub struct TextField {
170    pub start: u32,
171    pub end: u32,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub field_type: Option<String>,
174}
175
176/// Where a search looks: a slide, one shape and its descendants, or one story.
177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
178#[serde(rename_all = "camelCase", deny_unknown_fields)]
179pub struct FindScope {
180    pub slide_id: String,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub shape_id: Option<String>,
183    /// Requires `shape_id`.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub story_id: Option<String>,
186}
187
188#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct FindRequest {
191    pub text: String,
192    /// The whole deck when omitted.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub within: Option<FindScope>,
195    /// Defaults to 100; at most 10,000.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub limit: Option<u32>,
198}
199
200/// One search hit; `range` is reusable as a [`TextTarget::Range`].
201#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
202#[serde(rename_all = "camelCase")]
203pub struct FindMatch {
204    pub text: String,
205    pub range: TextRange,
206}
207
208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
209#[serde(rename_all = "camelCase")]
210pub struct FindResponse {
211    pub version: DocumentVersion,
212    pub matches: Vec<FindMatch>,
213    /// More matches exist than were returned.
214    pub truncated: bool,
215}
216
217pub type ReadOutcome = Result<ReadResponse, EditRefusal>;
218pub type FindOutcome = Result<FindResponse, EditRefusal>;
219
220fn missing(message: String, target: &EditTarget) -> EditFailure {
221    failure(EditFailureCode::MissingTarget, message, Some(target))
222}
223
224fn invalid(message: impl Into<String>, target: &EditTarget) -> EditFailure {
225    failure(EditFailureCode::InvalidStep, message, Some(target))
226}
227
228/// One paragraph of a projected story.
229pub(crate) struct ParagraphView {
230    pub id: String,
231    pub start: u32,
232    pub end: u32,
233    pub alignment: Option<String>,
234    pub line_breaks: Vec<u32>,
235    pub fields: Vec<TextField>,
236    pub editable: bool,
237}
238
239/// A story's projected text in one captured deck state.
240pub(crate) struct StoryView<'a> {
241    pub slide: &'a SlideSnapshot,
242    pub shape: &'a ShapeSnapshot,
243    pub story: &'a StorySnapshot,
244    units: Vec<u16>,
245    pub paragraphs: Vec<ParagraphView>,
246}
247
248impl<'a> StoryView<'a> {
249    pub(crate) fn build(
250        package: &PptxPackage,
251        slide: &'a SlideSnapshot,
252        shape: &'a ShapeSnapshot,
253        story: &'a StorySnapshot,
254    ) -> Result<Self, String> {
255        let body = source_body(package, slide, shape, &story.id);
256        let mut units: Vec<u16> = Vec::new();
257        let mut paragraphs = Vec::with_capacity(story.paragraphs.len());
258        for (index, paragraph) in story.paragraphs.iter().enumerate() {
259            if index > 0 {
260                units.push(u16::from(b'\n'));
261            }
262            let start = units.len() as u32;
263            let text: String = paragraph.runs.iter().map(|run| run.text.as_str()).collect();
264            units.extend(text.encode_utf16());
265            let end = units.len() as u32;
266            let line_breaks = (start..end)
267                .filter(|offset| units[*offset as usize] == u16::from(b'\n'))
268                .collect();
269            let (fields, editable) = body
270                .and_then(|body| source_paragraph(body, &story.id, &paragraph.id))
271                .map_or((Vec::new(), true), |source| {
272                    located_fields(source, &text, start)
273                });
274            paragraphs.push(ParagraphView {
275                id: paragraph.id.clone(),
276                start,
277                end,
278                alignment: paragraph.alignment.clone(),
279                line_breaks,
280                fields,
281                editable,
282            });
283        }
284        if units.len() as u64 + 1 != u64::from(story.length) {
285            return Err(format!(
286                "story {} holds content its text projection cannot address",
287                quoted(&story.id)
288            ));
289        }
290        Ok(Self {
291            slide,
292            shape,
293            story,
294            units,
295            paragraphs,
296        })
297    }
298
299    pub fn len(&self) -> u32 {
300        self.units.len() as u32
301    }
302
303    pub fn slice(&self, start: u32, end: u32) -> String {
304        String::from_utf16_lossy(&self.units[start as usize..end as usize])
305    }
306
307    fn is_scalar_boundary(&self, offset: u32) -> bool {
308        self.units
309            .get(offset as usize)
310            .is_none_or(|unit| !(0xDC00..=0xDFFF).contains(unit))
311    }
312
313    pub fn range(&self, start: u32, end: u32) -> TextRange {
314        TextRange {
315            slide_id: self.slide.id.clone(),
316            shape_id: self.shape.id.clone(),
317            story_id: self.story.id.clone(),
318            start,
319            end,
320        }
321    }
322
323    fn target(&self) -> EditTarget {
324        EditTarget::Story(StoryTarget {
325            slide_id: self.slide.id.clone(),
326            shape_id: self.shape.id.clone(),
327            story_id: self.story.id.clone(),
328        })
329    }
330
331    /// Visits exact, case-sensitive, paragraph-local occurrences in order, overlapping ones
332    /// included, until `visit` returns false.
333    fn each_occurrence(&self, text: &str, mut visit: impl FnMut(u32, u32) -> bool) {
334        let needle = Needle::new(text, true);
335        let span = text.encode_utf16().count() as u32;
336        for paragraph in &self.paragraphs {
337            let content = self.slice(paragraph.start, paragraph.end);
338            let (mut byte, mut position) = (0, paragraph.start);
339            for (from, _) in needle.find_overlapping(&content) {
340                position += content[byte..from].encode_utf16().count() as u32;
341                byte = from;
342                if !visit(position, position + span) {
343                    return;
344                }
345            }
346        }
347    }
348
349    fn record(&self) -> StoryText {
350        StoryText {
351            slide_id: self.slide.id.clone(),
352            shape_id: self.shape.id.clone(),
353            story_id: self.story.id.clone(),
354            text: String::from_utf16_lossy(&self.units),
355            paragraphs: self
356                .paragraphs
357                .iter()
358                .map(|paragraph| ParagraphText {
359                    list: None,
360                    paragraph_id: paragraph.id.clone(),
361                    start: paragraph.start,
362                    end: paragraph.end,
363                    line_breaks: paragraph.line_breaks.clone(),
364                    fields: paragraph.fields.clone(),
365                    editable: paragraph.editable,
366                })
367                .collect(),
368        }
369    }
370}
371
372/// A resolved text target: always inside one story, possibly across paragraphs.
373pub(crate) struct Selection<'a> {
374    pub view: StoryView<'a>,
375    pub start: u32,
376    pub end: u32,
377}
378
379impl Selection<'_> {
380    pub fn text(&self) -> String {
381        self.view.slice(self.start, self.end)
382    }
383
384    pub fn range(&self) -> TextRange {
385        self.view.range(self.start, self.end)
386    }
387
388    /// The paragraph holding the whole selection, when one does.
389    pub fn paragraph(&self) -> Option<&ParagraphView> {
390        self.view
391            .paragraphs
392            .iter()
393            .find(|paragraph| paragraph.start <= self.start && self.end <= paragraph.end)
394    }
395
396    /// The paragraphs a paragraph-level step at this selection changes.
397    pub fn selected(&self) -> Vec<&ParagraphView> {
398        self.view
399            .paragraphs
400            .iter()
401            .filter(|paragraph| {
402                selects_paragraph(self.start, self.end, paragraph.start, paragraph.end)
403            })
404            .collect()
405    }
406}
407
408/// A shape's position in a slide's tree, one-based from the top level.
409fn find_shape<'s>(
410    shapes: &'s [ShapeSnapshot],
411    id: &str,
412    depth: usize,
413) -> Option<(&'s ShapeSnapshot, usize)> {
414    shapes.iter().find_map(|shape| {
415        if shape.id == id {
416            Some((shape, depth))
417        } else {
418            find_shape(&shape.children, id, depth + 1)
419        }
420    })
421}
422
423/// Shapes and their descendants in document order.
424fn shapes_in_order(shapes: &[ShapeSnapshot]) -> Vec<&ShapeSnapshot> {
425    let mut ordered = Vec::new();
426    let mut pending: Vec<&ShapeSnapshot> = shapes.iter().rev().collect();
427    while let Some(shape) = pending.pop() {
428        ordered.push(shape);
429        pending.extend(shape.children.iter().rev());
430    }
431    ordered
432}
433
434/// The parsed text body a seeded story came from, found by the shape's seeded tree path.
435fn source_body<'p>(
436    package: &'p PptxPackage,
437    slide: &SlideSnapshot,
438    shape: &ShapeSnapshot,
439    story_id: &str,
440) -> Option<&'p TextBody> {
441    let source = package
442        .slides
443        .iter()
444        .find(|source| Some(&source.part_path) == slide.source_part_path.as_ref())?;
445    let path = shape
446        .id
447        .strip_prefix(slide.id.as_str())?
448        .strip_prefix(":shape:")?;
449    let mut nodes = source.shapes.as_slice();
450    let mut node = None;
451    for segment in path.split('.') {
452        let found = nodes.get(segment.parse::<usize>().ok()?)?;
453        nodes = match found {
454            ShapeNode::Group(group) => &group.children,
455            _ => &[],
456        };
457        node = Some(found);
458    }
459    let node = node.filter(|node| node.id() == shape.source_id)?;
460    let suffix = story_id
461        .strip_prefix("story:")?
462        .strip_prefix(shape.id.as_str())?;
463    match (node, suffix) {
464        (ShapeNode::Shape(shape), ":0") => shape.text.as_ref(),
465        (ShapeNode::GraphicFrame(frame), _) => {
466            let [row, cell] = crate::table::source_cell(shape, story_id)?;
467            match &frame.data {
468                GraphicFrameData::Table(table) => Some(&table.rows.get(row)?.cells.get(cell)?.text),
469                _ => None,
470            }
471        }
472        _ => None,
473    }
474}
475
476/// The source paragraph a seeded paragraph id names.
477pub(crate) fn source_paragraph<'b>(
478    body: &'b TextBody,
479    story_id: &str,
480    paragraph_id: &str,
481) -> Option<&'b TextParagraph> {
482    let index = paragraph_id
483        .strip_prefix("para:")?
484        .strip_prefix(story_id)?
485        .strip_prefix(':')?
486        .parse::<usize>()
487        .ok()?;
488    body.paragraphs.get(index)
489}
490
491/// The leading and trailing bytes `source` and `target` share, compared by character, the
492/// trailing ones within what the leading ones leave. Saving keeps source runs verbatim there.
493pub(crate) fn common_ends(source: &str, target: &str) -> (usize, usize) {
494    let prefix: usize = source
495        .chars()
496        .zip(target.chars())
497        .take_while(|(left, right)| left == right)
498        .map(|(value, _)| value.len_utf8())
499        .sum();
500    let suffix = source[prefix..]
501        .chars()
502        .rev()
503        .zip(target[prefix..].chars().rev())
504        .take_while(|(left, right)| left == right)
505        .map(|(value, _)| value.len_utf8())
506        .sum();
507    (prefix, suffix)
508}
509
510/// A source paragraph's field results as byte spans of `text`, its current text, empty results
511/// included. `None` when a field left the unchanged leading or trailing text: only there does
512/// saving provably keep a field.
513fn field_spans(
514    source: &TextParagraph,
515    text: &str,
516) -> Option<Vec<(std::ops::Range<usize>, Option<String>)>> {
517    let mut seeded = String::new();
518    let mut fields = Vec::new();
519    for run in &source.runs {
520        let start = seeded.len();
521        seeded.push_str(&run.text);
522        if run.field_id.is_some() || run.field_type.is_some() {
523            fields.push((start..seeded.len(), run.field_type.clone()));
524        }
525    }
526    let (prefix, suffix) = common_ends(&seeded, text);
527    let (tail, moved_tail) = (seeded.len() - suffix, text.len() - suffix);
528    fields
529        .into_iter()
530        .map(|(span, field_type)| {
531            if span.end <= prefix {
532                Some((span, field_type))
533            } else if span.start >= tail {
534                let start = moved_tail + (span.start - tail);
535                Some((start..start + span.len(), field_type))
536            } else {
537                None
538            }
539        })
540        .collect()
541}
542
543/// Field results of a seeded paragraph at their story offsets, and whether saving provably keeps
544/// them through a change to the paragraph: all were located and none is empty, since saving a
545/// changed paragraph drops empty results.
546fn located_fields(source: &TextParagraph, live: &str, start: u32) -> (Vec<TextField>, bool) {
547    let Some(spans) = field_spans(source, live) else {
548        return (Vec::new(), false);
549    };
550    let editable = spans.iter().all(|(span, _)| !span.is_empty());
551    let offset = |byte: usize| start + live[..byte].encode_utf16().count() as u32;
552    let fields = spans
553        .into_iter()
554        .map(|(span, field_type)| TextField {
555            start: offset(span.start),
556            end: offset(span.end),
557            field_type,
558        })
559        .collect();
560    (fields, editable)
561}
562
563pub(crate) fn field_paragraphs(
564    package: &PptxPackage,
565    snapshot: &DeckSnapshot,
566    story_id: &str,
567) -> Vec<String> {
568    snapshot
569        .slides
570        .iter()
571        .find_map(|slide| {
572            shapes_in_order(&slide.shapes)
573                .into_iter()
574                .find_map(|shape| {
575                    shape
576                        .text_stories
577                        .iter()
578                        .find(|story| story.id == story_id)?;
579                    let body = source_body(package, slide, shape, story_id)?;
580                    Some(
581                        body.paragraphs
582                            .iter()
583                            .enumerate()
584                            .filter(|(_, paragraph)| {
585                                paragraph
586                                    .runs
587                                    .iter()
588                                    .any(|run| run.field_id.is_some() || run.field_type.is_some())
589                            })
590                            .map(|(index, _)| {
591                                let storage = shape
592                                    .table_structure
593                                    .as_ref()
594                                    .and_then(|structure| {
595                                        shape
596                                            .text_stories
597                                            .iter()
598                                            .position(|story| story.id == story_id)
599                                            .and_then(|at| structure.story_keys.get(at))
600                                    })
601                                    .map_or(story_id, String::as_str);
602                                format!("para:{storage}:{index}")
603                            })
604                            .collect(),
605                    )
606                })
607        })
608        .unwrap_or_default()
609}
610
611/// Whether saving `paragraph_id` of `story_id` as `snapshot` holds it, changed, keeps every
612/// field result of its source paragraph: each non-empty, in the unchanged leading or trailing
613/// text, inside one run.
614pub(crate) fn keeps_fields(
615    package: &PptxPackage,
616    snapshot: &DeckSnapshot,
617    story_id: &str,
618    paragraph_id: &str,
619) -> bool {
620    let located = snapshot.slides.iter().find_map(|slide| {
621        shapes_in_order(&slide.shapes)
622            .into_iter()
623            .find_map(|shape| {
624                let story = shape
625                    .text_stories
626                    .iter()
627                    .find(|story| story.id == story_id)?;
628                Some((slide, shape, story))
629            })
630    });
631    let Some((slide, shape, story)) = located else {
632        return true;
633    };
634    let source = source_body(package, slide, shape, story_id)
635        .and_then(|body| source_paragraph(body, story_id, paragraph_id));
636    let paragraph = story
637        .paragraphs
638        .iter()
639        .find(|paragraph| paragraph.id == paragraph_id);
640    let (Some(source), Some(paragraph)) = (source, paragraph) else {
641        return true;
642    };
643    let text: String = paragraph.runs.iter().map(|run| run.text.as_str()).collect();
644    let Some(spans) = field_spans(source, &text) else {
645        return false;
646    };
647    let mut boundaries = Vec::with_capacity(paragraph.runs.len());
648    let mut end = 0;
649    for run in &paragraph.runs {
650        end += run.text.len();
651        boundaries.push(end);
652    }
653    spans.iter().all(|(span, _)| {
654        !span.is_empty()
655            && !boundaries
656                .iter()
657                .any(|boundary| span.start < *boundary && *boundary < span.end)
658    })
659}
660
661/// The captured deck state targets resolve against.
662pub(crate) struct Deck<'a> {
663    snapshot: &'a DeckSnapshot,
664    package: &'a PptxPackage,
665}
666
667impl<'a> Deck<'a> {
668    pub fn new(snapshot: &'a DeckSnapshot, package: &'a PptxPackage) -> Self {
669        Self { snapshot, package }
670    }
671
672    pub fn slide(
673        &self,
674        slide_id: &str,
675        requested: &EditTarget,
676    ) -> Result<&'a SlideSnapshot, EditFailure> {
677        self.snapshot
678            .slides
679            .iter()
680            .find(|slide| slide.id == slide_id)
681            .ok_or_else(|| {
682                missing(
683                    format!("slide {} was not found", quoted(slide_id)),
684                    requested,
685                )
686            })
687    }
688
689    /// Whether the slide's source part declares `p:bg`, fills the model cannot
690    /// hold included.
691    pub fn declares_source_background(&self, slide: &SlideSnapshot) -> bool {
692        crate::deck::source_slide(self.package, slide.source_part_path.as_deref())
693            .is_some_and(|source| source.declares_background)
694    }
695
696    /// Resolves a shape through its slide; the flag marks a shape at the top of the slide.
697    pub fn shape(
698        &self,
699        target: &ShapeTarget,
700        requested: &EditTarget,
701    ) -> Result<(&'a SlideSnapshot, &'a ShapeSnapshot, bool), EditFailure> {
702        let slide = self.slide(&target.slide_id, requested)?;
703        match find_shape(&slide.shapes, &target.shape_id, 1) {
704            Some((_, depth)) if depth > MAX_OWNERSHIP_DEPTH => Err(failure(
705                EditFailureCode::LimitExceeded,
706                format!(
707                    "shapes nested deeper than {MAX_OWNERSHIP_DEPTH} levels are not addressable"
708                ),
709                Some(requested),
710            )),
711            Some((shape, depth)) => Ok((slide, shape, depth == 1)),
712            None => Err(
713                match self
714                    .snapshot
715                    .slides
716                    .iter()
717                    .find(|other| find_shape(&other.shapes, &target.shape_id, 1).is_some())
718                {
719                    Some(owner) => invalid(
720                        format!(
721                            "shape {} belongs to slide {}, not {}",
722                            quoted(&target.shape_id),
723                            quoted(&owner.id),
724                            quoted(&target.slide_id)
725                        ),
726                        requested,
727                    ),
728                    None => missing(
729                        format!("shape {} was not found", quoted(&target.shape_id)),
730                        requested,
731                    ),
732                },
733            ),
734        }
735    }
736
737    pub fn story(
738        &self,
739        target: &StoryTarget,
740        requested: &EditTarget,
741    ) -> Result<StoryView<'a>, EditFailure> {
742        let shape_target = ShapeTarget {
743            slide_id: target.slide_id.clone(),
744            shape_id: target.shape_id.clone(),
745        };
746        let (slide, shape, _) = self.shape(&shape_target, requested)?;
747        let Some(story) = shape
748            .text_stories
749            .iter()
750            .find(|story| story.id == target.story_id)
751        else {
752            let owner = self
753                .snapshot
754                .slides
755                .iter()
756                .flat_map(|slide| shapes_in_order(&slide.shapes))
757                .find(|other| {
758                    other
759                        .text_stories
760                        .iter()
761                        .any(|story| story.id == target.story_id)
762                });
763            return Err(match owner {
764                Some(owner) => invalid(
765                    format!(
766                        "story {} belongs to shape {}, not {}",
767                        quoted(&target.story_id),
768                        quoted(&owner.id),
769                        quoted(&target.shape_id)
770                    ),
771                    requested,
772                ),
773                None => missing(
774                    format!("story {} was not found", quoted(&target.story_id)),
775                    requested,
776                ),
777            });
778        };
779        StoryView::build(self.package, slide, shape, story)
780            .map_err(|message| failure(EditFailureCode::Unsupported, message, Some(requested)))
781    }
782
783    /// Resolves `target` against this state.
784    pub fn text(&self, target: &TextTarget) -> Result<Selection<'a>, EditFailure> {
785        let requested = EditTarget::from(target.clone());
786        match target {
787            TextTarget::Range(range) => {
788                let view = self.story(&range.story(), &requested)?;
789                let (start, end) = (range.start, range.end);
790                if end < start {
791                    return Err(invalid(
792                        format!("range end {end} precedes its start {start}"),
793                        &requested,
794                    ));
795                }
796                if end > view.len() {
797                    return Err(invalid(
798                        format!(
799                            "offset {end} exceeds the story's text length {}",
800                            view.len()
801                        ),
802                        &requested,
803                    ));
804                }
805                if !view.is_scalar_boundary(start) || !view.is_scalar_boundary(end) {
806                    return Err(invalid(
807                        "range offsets must not split a surrogate pair",
808                        &requested,
809                    ));
810                }
811                Ok(Selection { view, start, end })
812            }
813            TextTarget::Search { within, text } => {
814                if text.is_empty() {
815                    return Err(invalid("search text must not be empty", &requested));
816                }
817                let view = self.story(within, &requested)?;
818                let mut hits = Vec::with_capacity(2);
819                view.each_occurrence(text, |start, end| {
820                    hits.push((start, end));
821                    hits.len() < 2
822                });
823                match hits.as_slice() {
824                    [] => Err(missing(
825                        format!("search text {} was not found", quoted(text)),
826                        &requested,
827                    )),
828                    [(start, end)] => Ok(Selection {
829                        start: *start,
830                        end: *end,
831                        view,
832                    }),
833                    _ => Err(failure(
834                        EditFailureCode::AmbiguousTarget,
835                        format!("search text {} occurs more than once", quoted(text)),
836                        Some(&requested),
837                    )),
838                }
839            }
840        }
841    }
842
843    fn stories_under(
844        &self,
845        slide: &'a SlideSnapshot,
846        shapes: &'a [ShapeSnapshot],
847    ) -> Result<Vec<StoryView<'a>>, EditFailure> {
848        let mut views = Vec::new();
849        for shape in shapes_in_order(shapes) {
850            for story in &shape.text_stories {
851                let view =
852                    StoryView::build(self.package, slide, shape, story).map_err(|message| {
853                        let target = EditTarget::Story(StoryTarget {
854                            slide_id: slide.id.clone(),
855                            shape_id: shape.id.clone(),
856                            story_id: story.id.clone(),
857                        });
858                        failure(EditFailureCode::Unsupported, message, Some(&target))
859                    })?;
860                views.push(view);
861            }
862        }
863        Ok(views)
864    }
865
866    /// The stories a search looks through, in document order.
867    fn scope(&self, within: Option<&FindScope>) -> Result<Vec<StoryView<'a>>, EditFailure> {
868        let Some(within) = within else {
869            let mut views = Vec::new();
870            for slide in &self.snapshot.slides {
871                views.extend(self.stories_under(slide, &slide.shapes)?);
872            }
873            return Ok(views);
874        };
875        match (&within.shape_id, &within.story_id) {
876            (None, None) => {
877                let requested = EditTarget::Slide(SlideTarget {
878                    slide_id: within.slide_id.clone(),
879                });
880                let slide = self.slide(&within.slide_id, &requested)?;
881                self.stories_under(slide, &slide.shapes)
882            }
883            (None, Some(_)) => Err(invalid(
884                "a story scope needs its shape",
885                &EditTarget::Slide(SlideTarget {
886                    slide_id: within.slide_id.clone(),
887                }),
888            )),
889            (Some(shape_id), None) => {
890                let target = ShapeTarget {
891                    slide_id: within.slide_id.clone(),
892                    shape_id: shape_id.clone(),
893                };
894                let (slide, shape, _) = self.shape(&target, &EditTarget::Shape(target.clone()))?;
895                self.stories_under(slide, std::slice::from_ref(shape))
896            }
897            (Some(shape_id), Some(story_id)) => {
898                let target = StoryTarget {
899                    slide_id: within.slide_id.clone(),
900                    shape_id: shape_id.clone(),
901                    story_id: story_id.clone(),
902                };
903                Ok(vec![
904                    self.story(&target, &EditTarget::Story(target.clone()))?,
905                ])
906            }
907        }
908    }
909}
910
911impl DeckSession {
912    /// Slides and the projected text of their stories, with the version they were read at.
913    pub fn read_content(&self, request: &ReadRequest) -> EditResult<ReadOutcome> {
914        let version = self.version();
915        if !ByteBudget::new(MAX_REQUEST_BYTES).charge(request) {
916            return Ok(Err(refusal(version, request_limit())));
917        }
918        let snapshot = self.snapshot()?;
919        self.read_snapshot(request, snapshot, version)
920    }
921
922    #[cfg(any(feature = "wasm", test))]
923    pub(crate) fn read_command_content(
924        &self,
925        request: &ReadRequest,
926    ) -> EditResult<Result<CommandReadResponse, EditRefusal>> {
927        self.enable_history_receipts()?;
928        let version = self.version();
929        if !ByteBudget::new(MAX_REQUEST_BYTES).charge(request) {
930            return Ok(Err(refusal(version, request_limit())));
931        }
932        let snapshot = match &request.slide_ids {
933            Some(ids) => crate::deck::snapshot_selected_slides(&self.doc, &self.package, ids)?,
934            None => self.snapshot()?,
935        };
936        let read = match self.read_snapshot(request, snapshot, version)? {
937            Ok(read) => read,
938            Err(error) => return Ok(Err(error)),
939        };
940        let mut sources = Vec::new();
941        for slide in &read.slides {
942            for shape in shapes_in_order(&slide.shapes) {
943                for story in &shape.text_stories {
944                    let Some(body) = source_body(&self.package, slide, shape, &story.id) else {
945                        continue;
946                    };
947                    let mut paragraphs = Vec::new();
948                    for paragraph in &story.paragraphs {
949                        let Some(source) = source_paragraph(body, &story.id, &paragraph.id) else {
950                            continue;
951                        };
952                        let text: String =
953                            source.runs.iter().map(|run| run.text.as_str()).collect();
954                        let (fields, editable) = located_fields(source, &text, 0);
955                        if !fields.is_empty() || !editable {
956                            paragraphs.push(CommandParagraphSource {
957                                paragraph_id: paragraph.id.clone(),
958                                text,
959                                fields,
960                                editable,
961                            });
962                        }
963                    }
964                    sources.push(CommandStorySource {
965                        story_id: story.id.clone(),
966                        paragraphs,
967                    });
968                }
969            }
970        }
971        Ok(Ok(CommandReadResponse { read, sources }))
972    }
973
974    fn read_snapshot(
975        &self,
976        request: &ReadRequest,
977        mut snapshot: DeckSnapshot,
978        version: DocumentVersion,
979    ) -> EditResult<ReadOutcome> {
980        for slide in &mut snapshot.slides {
981            slide.hidden = slide_hidden(&self.package, slide.source_part_path.as_deref());
982            let parents = SlideParents::resolve(
983                &self.package,
984                slide.source_part_path.as_deref(),
985                slide.layout_part_path.as_deref(),
986            );
987            slide.layout = parents.layout.map(|layout| SlideLayoutSnapshot {
988                name: layout.name.clone(),
989                r#type: layout.layout_type.clone(),
990            });
991            slide.title = shapes_in_order(&slide.shapes)
992                .into_iter()
993                .find(|shape| is_title_placeholder(shape.placeholder.as_ref()))
994                .filter(|shape| {
995                    shape
996                        .text_stories
997                        .iter()
998                        .flat_map(|story| &story.paragraphs)
999                        .flat_map(|paragraph| &paragraph.runs)
1000                        .any(|run| !run.text.is_empty())
1001                })
1002                .map(|shape| {
1003                    shape
1004                        .text_stories
1005                        .iter()
1006                        .map(StorySnapshot::plain_text)
1007                        .collect::<Vec<_>>()
1008                        .join("\n")
1009                });
1010        }
1011        let deck = Deck::new(&snapshot, &self.package);
1012        let read = || -> Result<(Vec<SlideSnapshot>, Vec<StoryText>), EditFailure> {
1013            let slides: Vec<&SlideSnapshot> = match &request.slide_ids {
1014                None => snapshot.slides.iter().collect(),
1015                Some(ids) => {
1016                    for id in ids {
1017                        deck.slide(
1018                            id,
1019                            &EditTarget::Slide(SlideTarget {
1020                                slide_id: id.clone(),
1021                            }),
1022                        )?;
1023                    }
1024                    snapshot
1025                        .slides
1026                        .iter()
1027                        .filter(|slide| ids.contains(&slide.id))
1028                        .collect()
1029                }
1030            };
1031            let mut budget = ByteBudget::new(MAX_RESPONSE_BYTES);
1032            let over = |target: EditTarget| {
1033                failure(
1034                    EditFailureCode::LimitExceeded,
1035                    format!("a read returns at most {MAX_RESPONSE_BYTES} bytes; read fewer slides"),
1036                    Some(&target),
1037                )
1038            };
1039            let mut stories = Vec::new();
1040            for slide in &slides {
1041                if !budget.charge(slide) {
1042                    return Err(over(EditTarget::Slide(SlideTarget {
1043                        slide_id: slide.id.clone(),
1044                    })));
1045                }
1046                for view in deck.stories_under(slide, &slide.shapes)? {
1047                    let mut record = view.record();
1048                    let parents = SlideParents::resolve(
1049                        &self.package,
1050                        slide.source_part_path.as_deref(),
1051                        slide.layout_part_path.as_deref(),
1052                    );
1053                    let primary = source_body(&self.package, slide, view.shape, &view.story.id);
1054                    let cascade = if view.shape.kind == ShapeKind::GraphicFrame {
1055                        ParagraphCascade {
1056                            primary,
1057                            ..Default::default()
1058                        }
1059                    } else {
1060                        shape_cascade(
1061                            &parents,
1062                            &self.package.presentation,
1063                            view.shape.placeholder.as_ref(),
1064                            primary,
1065                        )
1066                    };
1067                    let mut counters = ListCounters::default();
1068                    for (index, (paragraph, source)) in record
1069                        .paragraphs
1070                        .iter_mut()
1071                        .zip(&view.story.paragraphs)
1072                        .enumerate()
1073                    {
1074                        let authored = source
1075                            .bullet_json
1076                            .as_deref()
1077                            .and_then(|json| serde_json::from_str::<pptx_parse::Bullet>(json).ok());
1078                        let properties = cascade.properties(index, source.level, authored.as_ref());
1079                        paragraph.list = effective_list(
1080                            &mut counters,
1081                            &properties,
1082                            source.level,
1083                            source.runs.iter().any(|run| !run.text.is_empty()),
1084                        );
1085                    }
1086                    if !budget.charge(&record) {
1087                        return Err(over(view.target()));
1088                    }
1089                    stories.push(record);
1090                }
1091            }
1092            Ok((slides.into_iter().cloned().collect(), stories))
1093        };
1094        Ok(match read() {
1095            Ok((slides, stories)) => Ok(ReadResponse {
1096                version,
1097                slides,
1098                stories,
1099            }),
1100            Err(failure) => Err(refusal(version, failure)),
1101        })
1102    }
1103
1104    /// Exact, case-sensitive, paragraph-local matches; overlapping occurrences count separately.
1105    pub fn find_text(&self, request: &FindRequest) -> EditResult<FindOutcome> {
1106        let version = self.version();
1107        if !ByteBudget::new(MAX_REQUEST_BYTES).charge(request) {
1108            return Ok(Err(refusal(version, request_limit())));
1109        }
1110        let limit = request
1111            .limit
1112            .unwrap_or(FIND_DEFAULT_LIMIT)
1113            .min(FIND_MAX_LIMIT) as usize;
1114        let snapshot = self.snapshot()?;
1115        let deck = Deck::new(&snapshot, &self.package);
1116        let find = || -> Result<(Vec<FindMatch>, bool), EditFailure> {
1117            if request.text.is_empty() {
1118                return Err(failure(
1119                    EditFailureCode::InvalidStep,
1120                    "search text must not be empty",
1121                    None,
1122                ));
1123            }
1124            let mut budget = ByteBudget::new(MAX_RESPONSE_BYTES);
1125            let mut matches = Vec::new();
1126            let mut truncated = false;
1127            for view in deck.scope(request.within.as_ref())? {
1128                view.each_occurrence(&request.text, |start, end| {
1129                    let found = FindMatch {
1130                        text: request.text.clone(),
1131                        range: view.range(start, end),
1132                    };
1133                    truncated = matches.len() == limit || !budget.charge(&found);
1134                    if !truncated {
1135                        matches.push(found);
1136                    }
1137                    !truncated
1138                });
1139                if truncated {
1140                    break;
1141                }
1142            }
1143            Ok((matches, truncated))
1144        };
1145        Ok(match find() {
1146            Ok((matches, truncated)) => Ok(FindResponse {
1147                version,
1148                matches,
1149                truncated,
1150            }),
1151            Err(failure) => Err(refusal(version, failure)),
1152        })
1153    }
1154}