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