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#[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 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}