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