1use 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
20const MAX_OWNERSHIP_DEPTH: usize = 32;
22const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
24const FIND_DEFAULT_LIMIT: u32 = 100;
26const FIND_MAX_LIMIT: u32 = 10_000;
28
29#[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#[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#[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#[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 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 #[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 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#[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 pub text: String,
137 pub paragraphs: Vec<ParagraphText>,
138}
139
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct ParagraphText {
144 pub paragraph_id: String,
145 pub start: u32,
147 pub end: u32,
148 pub line_breaks: Vec<u32>,
150 pub fields: Vec<TextField>,
152 pub editable: bool,
155}
156
157#[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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub within: Option<FindScope>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub limit: Option<u32>,
189}
190
191#[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 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
219pub(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
230pub(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, ¶graph.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 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
362pub(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 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 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
398fn 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
413fn 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
424fn 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
466pub(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
481pub(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
500fn 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
533fn 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
601pub(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 ¶graph.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
651pub(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 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 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 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 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 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, ¶graph.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 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}