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#[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
26pub(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 fn find_all<'a>(&'a self, text: &'a str) -> Matches<'a> {
47 self.matches(text, false)
48 }
49
50 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
100pub(crate) struct Matches<'a> {
102 needle: &'a str,
103 haystack: Cow<'a, str>,
104 origins: Vec<(usize, usize)>,
105 cursor: usize,
106 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 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}