Skip to main content

pptx_edit/
search.rs

1use std::borrow::Cow;
2use std::collections::HashSet;
3
4use serde::Serialize;
5use yrs::{Map, TextRef, Transact};
6
7use crate::deck::{
8    live_shape_order, map_string_array, required_map, required_order, shape_ref, slide_ref,
9    slide_shape_order, string_array_ref,
10};
11use crate::{DeckSession, EditError, EditResult, STORIES, story::snapshot_story};
12
13/// Story-local UTF-16 offsets.
14#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct TextSearchMatch {
17    pub slide_index: usize,
18    pub slide_id: String,
19    pub shape_id: String,
20    pub story_id: String,
21    pub start: u32,
22    pub end: u32,
23    pub text: String,
24}
25
26/// Literal query with Unicode simple case-insensitive matching.
27pub(crate) struct Needle {
28    folded: String,
29    case_sensitive: bool,
30}
31
32impl Needle {
33    pub(crate) fn new(query: &str, case_sensitive: bool) -> Self {
34        let folded = if case_sensitive {
35            query.to_owned()
36        } else {
37            query.chars().map(fold_char).collect()
38        };
39        Self {
40            folded,
41            case_sensitive,
42        }
43    }
44
45    /// Lazily yields the non-overlapping byte ranges of `text` equal to the query, in order.
46    fn find_all<'a>(&'a self, text: &'a str) -> Matches<'a> {
47        self.matches(text, false)
48    }
49
50    /// Like [`Needle::find_all`], but every occurrence counts, overlapping ones included.
51    pub(crate) fn find_overlapping<'a>(&'a self, text: &'a str) -> Matches<'a> {
52        self.matches(text, true)
53    }
54
55    fn matches<'a>(&'a self, text: &'a str, overlapping: bool) -> Matches<'a> {
56        let (haystack, origins) = if self.case_sensitive {
57            (Cow::Borrowed(text), Vec::new())
58        } else {
59            let mut folded = String::with_capacity(text.len());
60            let mut origins = Vec::with_capacity(text.len() + 1);
61            for (offset, ch) in text.char_indices() {
62                origins.push((folded.len(), offset));
63                folded.push(fold_char(ch));
64            }
65            origins.push((folded.len(), text.len()));
66            (Cow::Owned(folded), origins)
67        };
68        Matches {
69            needle: &self.folded,
70            haystack,
71            origins,
72            cursor: 0,
73            overlapping,
74        }
75    }
76}
77
78fn fold_char(ch: char) -> char {
79    match ch {
80        '\u{0131}' => return ch,
81        '\u{1FD3}' => return '\u{0390}',
82        '\u{1FE3}' => return '\u{03B0}',
83        '\u{FB05}' => return '\u{FB06}',
84        _ => {}
85    }
86    let mut upper = ch.to_uppercase();
87    if let (Some(single), None) = (upper.next(), upper.next()) {
88        let mut lower = single.to_lowercase();
89        if let (Some(single), None) = (lower.next(), lower.next()) {
90            return single;
91        }
92    }
93    let mut lower = ch.to_lowercase();
94    match (lower.next(), lower.next()) {
95        (Some(single), None) => single,
96        _ => ch,
97    }
98}
99
100/// Byte ranges in the original text; empty `origins` means the haystack is that text.
101pub(crate) struct Matches<'a> {
102    needle: &'a str,
103    haystack: Cow<'a, str>,
104    origins: Vec<(usize, usize)>,
105    cursor: usize,
106    /// Resume one character after a match's start rather than at its end.
107    overlapping: bool,
108}
109
110impl Iterator for Matches<'_> {
111    type Item = (usize, usize);
112
113    fn next(&mut self) -> Option<Self::Item> {
114        if self.needle.is_empty() {
115            return None;
116        }
117        let start = self.cursor + self.haystack[self.cursor..].find(self.needle)?;
118        let end = start + self.needle.len();
119        self.cursor = if self.overlapping {
120            start
121                + self.haystack[start..]
122                    .chars()
123                    .next()
124                    .map_or(1, char::len_utf8)
125        } else {
126            end
127        };
128        Some((self.original(start), self.original(end)))
129    }
130}
131
132impl Matches<'_> {
133    fn original(&self, folded: usize) -> usize {
134        if self.origins.is_empty() {
135            return folded;
136        }
137        match self.origins.binary_search_by_key(&folded, |&(at, _)| at) {
138            Ok(index) => self.origins[index].1,
139            Err(index) => self.origins[index.min(self.origins.len() - 1)].1,
140        }
141    }
142}
143
144impl DeckSession {
145    /// Searches slides, shapes, and table cells in document order.
146    pub fn search_text(
147        &self,
148        query: &str,
149        case_sensitive: bool,
150        limit: Option<usize>,
151    ) -> EditResult<Vec<TextSearchMatch>> {
152        let limit = limit.unwrap_or(usize::MAX);
153        if query.is_empty() || limit == 0 {
154            return Ok(Vec::new());
155        }
156        let needle = Needle::new(query, case_sensitive);
157        let txn = self.doc.transact();
158        let stories = required_map(&txn, STORIES)?;
159        let mut seen_slides = HashSet::new();
160        let mut matches = Vec::new();
161        for (slide_index, slide_id) in string_array_ref(&required_order(&txn)?, &txn)
162            .into_iter()
163            .filter(|id| seen_slides.insert(id.clone()))
164            .enumerate()
165        {
166            let slide = slide_ref(&txn, &slide_id)?;
167            let mut shapes = live_shape_order(&slide_shape_order(&slide, &txn)?, &txn)?;
168            shapes.reverse();
169            let mut seen_shapes = HashSet::new();
170            while let Some(shape_id) = shapes.pop() {
171                if !seen_shapes.insert(shape_id.clone()) {
172                    continue;
173                }
174                let shape = shape_ref(&txn, &shape_id)?;
175                for story_id in map_string_array(&shape, &txn, "textStories")? {
176                    let story = stories
177                        .get(&txn, &story_id)
178                        .and_then(|value| value.cast::<TextRef>().ok())
179                        .ok_or_else(|| EditError::StoryNotFound(story_id.clone()))?;
180                    let text = snapshot_story(&story, &txn, &story_id)?.plain_text();
181                    let mut byte_offset = 0;
182                    let mut position = 0;
183                    for (from, to) in needle.find_all(&text) {
184                        position += text[byte_offset..from].encode_utf16().count() as u32;
185                        let found = &text[from..to];
186                        let end = position + found.encode_utf16().count() as u32;
187                        matches.push(TextSearchMatch {
188                            slide_index,
189                            slide_id: slide_id.clone(),
190                            shape_id: shape_id.clone(),
191                            story_id: story_id.clone(),
192                            start: position,
193                            end,
194                            text: found.to_owned(),
195                        });
196                        if matches.len() == limit {
197                            return Ok(matches);
198                        }
199                        byte_offset = to;
200                        position = end;
201                    }
202                }
203                shapes.extend(
204                    map_string_array(&shape, &txn, "children")?
205                        .into_iter()
206                        .rev(),
207                );
208            }
209        }
210        Ok(matches)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::{EditCtx, ShapeDraft, ShapeRect, TextStyle};
218
219    #[test]
220    fn literal_unicode_offsets_paragraphs_limits_and_read_only() {
221        let session = DeckSession::open(
222            include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx"),
223            84001,
224        )
225        .unwrap();
226        let slide = session.snapshot().unwrap().slides[0].id.clone();
227        let added = session
228            .add_text_box(
229                &EditCtx::local("test"),
230                &slide,
231                &ShapeDraft {
232                    name: "Search".to_owned(),
233                    rect: ShapeRect {
234                        x: 0,
235                        y: 0,
236                        width: 1_000_000,
237                        height: 1_000_000,
238                    },
239                    text: "πŸ˜€Ξ£Ο‚Οƒ [a]\n[A]".to_owned(),
240                    style: TextStyle::default(),
241                },
242            )
243            .unwrap();
244        let before = session.encode_state_as_update_v1();
245        let matches = session.search_text("Οƒ", false, None).unwrap();
246        assert_eq!(
247            matches.iter().map(|m| (m.start, m.end)).collect::<Vec<_>>(),
248            [(2, 3), (3, 4), (4, 5)]
249        );
250        assert!(matches.iter().all(|m| m.shape_id == added.shape_id));
251        assert_eq!(
252            session
253                .search_text("[a]", false, None)
254                .unwrap()
255                .iter()
256                .map(|m| m.start)
257                .collect::<Vec<_>>(),
258            [6, 10]
259        );
260        assert_eq!(session.search_text("[a]", true, None).unwrap().len(), 1);
261        assert_eq!(
262            session.search_text("Οƒ", false, Some(1)).unwrap(),
263            matches[..1]
264        );
265        assert!(session.search_text("", false, None).unwrap().is_empty());
266        assert!(session.search_text("Οƒ", false, Some(0)).unwrap().is_empty());
267        assert_eq!(session.encode_state_as_update_v1(), before);
268    }
269
270    #[test]
271    fn searches_nested_group_shapes() {
272        let session = DeckSession::open(
273            include_bytes!("../tests/fixtures/deck-schema-v2-nested-connectors.pptx"),
274            84002,
275        )
276        .unwrap();
277        let snapshot = session.snapshot().unwrap();
278        let slide = &snapshot.slides[0];
279        let mut shapes: Vec<_> = slide.shapes.iter().collect();
280        let mut nested_story = None;
281        while let Some(shape) = shapes.pop() {
282            for child in &shape.children {
283                if let Some(story) = child
284                    .text_stories
285                    .iter()
286                    .find(|story| story.plain_text().contains("Before"))
287                {
288                    nested_story = Some((child.id.clone(), story.id.clone()));
289                }
290                shapes.push(child);
291            }
292        }
293        let (shape_id, story_id) = nested_story.expect("fixture has nested text");
294        let matches = session.search_text("before", false, None).unwrap();
295        assert_eq!(matches.len(), 1);
296        assert_eq!(matches[0].shape_id, shape_id);
297        assert_eq!(matches[0].story_id, story_id);
298        assert_eq!(matches[0].slide_id, slide.id);
299    }
300
301    #[test]
302    fn needle_equates_shared_uppercase_forms_and_keeps_byte_ranges() {
303        let text = "\u{212A}elvin k STRASSE ẞ ß AAA";
304        let ranges = |query, case_sensitive| {
305            Needle::new(query, case_sensitive)
306                .find_all(text)
307                .collect::<Vec<_>>()
308        };
309        assert_eq!(ranges("k", false), [(0, 3), (9, 10)]);
310        assert_eq!(ranges("k", true), [(9, 10)]);
311        assert_eq!(ranges("ß", false), [(19, 22), (23, 25)]);
312        assert_eq!(ranges("ss", false), [(15, 17)]);
313        assert_eq!(ranges("aa", false), [(26, 28)]);
314        assert_eq!(Needle::new("", false).find_all(text).next(), None);
315    }
316
317    #[test]
318    fn overlapping_matches_count_every_occurrence() {
319        let needle = Needle::new("aa", true);
320        assert_eq!(needle.find_all("aaa").collect::<Vec<_>>(), [(0, 2)]);
321        assert_eq!(
322            needle.find_overlapping("aaa").collect::<Vec<_>>(),
323            [(0, 2), (1, 3)]
324        );
325        let emoji = Needle::new("πŸ˜€πŸ˜€", true);
326        assert_eq!(
327            emoji.find_overlapping("πŸ˜€πŸ˜€πŸ˜€").collect::<Vec<_>>(),
328            [(0, 8), (4, 12)]
329        );
330    }
331}