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