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, live_slide_ids, map_string_array, required_map, shape_ref, slide_ref,
9    slide_shape_order,
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 matches = Vec::new();
160        for (slide_index, slide_id) in live_slide_ids(&txn)?.into_iter().enumerate() {
161            let slide = slide_ref(&txn, &slide_id)?;
162            let mut shapes = live_shape_order(&slide_shape_order(&slide, &txn)?, &txn)?;
163            shapes.reverse();
164            let mut seen_shapes = HashSet::new();
165            while let Some(shape_id) = shapes.pop() {
166                if !seen_shapes.insert(shape_id.clone()) {
167                    continue;
168                }
169                let shape = shape_ref(&txn, &shape_id)?;
170                for story_id in map_string_array(&shape, &txn, "textStories")? {
171                    let key = crate::table::storage_key(&txn, &story_id)?;
172                    let story = stories
173                        .get(&txn, &key)
174                        .and_then(|value| value.cast::<TextRef>().ok())
175                        .ok_or_else(|| EditError::StoryNotFound(story_id.clone()))?;
176                    let text = snapshot_story(&story, &txn, &story_id)?.plain_text();
177                    let mut byte_offset = 0;
178                    let mut position = 0;
179                    for (from, to) in needle.find_all(&text) {
180                        position += text[byte_offset..from].encode_utf16().count() as u32;
181                        let found = &text[from..to];
182                        let end = position + found.encode_utf16().count() as u32;
183                        matches.push(TextSearchMatch {
184                            slide_index,
185                            slide_id: slide_id.clone(),
186                            shape_id: shape_id.clone(),
187                            story_id: story_id.clone(),
188                            start: position,
189                            end,
190                            text: found.to_owned(),
191                        });
192                        if matches.len() == limit {
193                            return Ok(matches);
194                        }
195                        byte_offset = to;
196                        position = end;
197                    }
198                }
199                shapes.extend(
200                    map_string_array(&shape, &txn, "children")?
201                        .into_iter()
202                        .rev(),
203                );
204            }
205        }
206        Ok(matches)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::{EditCtx, ShapeDraft, ShapeRect, TextStyle};
214
215    #[test]
216    fn literal_unicode_offsets_paragraphs_limits_and_read_only() {
217        let session = DeckSession::open(
218            include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx"),
219            84001,
220        )
221        .unwrap();
222        let slide = session.snapshot().unwrap().slides[0].id.clone();
223        let added = session
224            .add_text_box(
225                &EditCtx::local("test"),
226                &slide,
227                &ShapeDraft {
228                    name: "Search".to_owned(),
229                    rect: ShapeRect {
230                        x: 0,
231                        y: 0,
232                        width: 1_000_000,
233                        height: 1_000_000,
234                    },
235                    text: "πŸ˜€Ξ£Ο‚Οƒ [a]\n[A]".to_owned(),
236                    style: TextStyle::default(),
237                },
238            )
239            .unwrap();
240        let before = session.encode_state_as_update_v1();
241        let matches = session.search_text("Οƒ", false, None).unwrap();
242        assert_eq!(
243            matches.iter().map(|m| (m.start, m.end)).collect::<Vec<_>>(),
244            [(2, 3), (3, 4), (4, 5)]
245        );
246        assert!(matches.iter().all(|m| m.shape_id == added.shape_id));
247        assert_eq!(
248            session
249                .search_text("[a]", false, None)
250                .unwrap()
251                .iter()
252                .map(|m| m.start)
253                .collect::<Vec<_>>(),
254            [6, 10]
255        );
256        assert_eq!(session.search_text("[a]", true, None).unwrap().len(), 1);
257        assert_eq!(
258            session.search_text("Οƒ", false, Some(1)).unwrap(),
259            matches[..1]
260        );
261        assert!(session.search_text("", false, None).unwrap().is_empty());
262        assert!(session.search_text("Οƒ", false, Some(0)).unwrap().is_empty());
263        assert_eq!(session.encode_state_as_update_v1(), before);
264    }
265
266    #[test]
267    fn searches_nested_group_shapes() {
268        let session = DeckSession::open(
269            include_bytes!("../tests/fixtures/deck-schema-v2-nested-connectors.pptx"),
270            84002,
271        )
272        .unwrap();
273        let snapshot = session.snapshot().unwrap();
274        let slide = &snapshot.slides[0];
275        let mut shapes: Vec<_> = slide.shapes.iter().collect();
276        let mut nested_story = None;
277        while let Some(shape) = shapes.pop() {
278            for child in &shape.children {
279                if let Some(story) = child
280                    .text_stories
281                    .iter()
282                    .find(|story| story.plain_text().contains("Before"))
283                {
284                    nested_story = Some((child.id.clone(), story.id.clone()));
285                }
286                shapes.push(child);
287            }
288        }
289        let (shape_id, story_id) = nested_story.expect("fixture has nested text");
290        let matches = session.search_text("before", false, None).unwrap();
291        assert_eq!(matches.len(), 1);
292        assert_eq!(matches[0].shape_id, shape_id);
293        assert_eq!(matches[0].story_id, story_id);
294        assert_eq!(matches[0].slide_id, slide.id);
295    }
296
297    #[test]
298    fn needle_equates_shared_uppercase_forms_and_keeps_byte_ranges() {
299        let text = "\u{212A}elvin k STRASSE ẞ ß AAA";
300        let ranges = |query, case_sensitive| {
301            Needle::new(query, case_sensitive)
302                .find_all(text)
303                .collect::<Vec<_>>()
304        };
305        assert_eq!(ranges("k", false), [(0, 3), (9, 10)]);
306        assert_eq!(ranges("k", true), [(9, 10)]);
307        assert_eq!(ranges("ß", false), [(19, 22), (23, 25)]);
308        assert_eq!(ranges("ss", false), [(15, 17)]);
309        assert_eq!(ranges("aa", false), [(26, 28)]);
310        assert_eq!(Needle::new("", false).find_all(text).next(), None);
311    }
312
313    #[test]
314    fn overlapping_matches_count_every_occurrence() {
315        let needle = Needle::new("aa", true);
316        assert_eq!(needle.find_all("aaa").collect::<Vec<_>>(), [(0, 2)]);
317        assert_eq!(
318            needle.find_overlapping("aaa").collect::<Vec<_>>(),
319            [(0, 2), (1, 3)]
320        );
321        let emoji = Needle::new("πŸ˜€πŸ˜€", true);
322        assert_eq!(
323            emoji.find_overlapping("πŸ˜€πŸ˜€πŸ˜€").collect::<Vec<_>>(),
324            [(0, 8), (4, 12)]
325        );
326    }
327}