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#[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 pub text: String,
110 pub paragraphs: Vec<ParagraphText>,
111}
112
113#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115#[serde(rename_all = "camelCase")]
116pub struct ParagraphText {
117 pub paragraph_id: String,
118 pub start: u32,
120 pub end: u32,
121 pub line_breaks: Vec<u32>,
123 pub fields: Vec<TextField>,
125 pub editable: bool,
128}
129
130#[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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub within: Option<FindScope>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub limit: Option<u32>,
162}
163
164#[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 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
192pub(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
203pub(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, ¶graph.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 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
335pub(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 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 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
371fn 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
386fn 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
397fn 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
446pub(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
461pub(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
480fn 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
513fn 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
533pub(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 ¶graph.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
583pub(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 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 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 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 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 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}