1use std::sync::Arc;
2
3use ooxml_drawingml::{Theme, resolve_color_value_to_hex_with_theme};
4use pptx_parse::{RunProperties, TextBody};
5use yrs::types::Attrs;
6use yrs::types::text::YChange;
7use yrs::{
8 Any, Map, MapPrelim, MapRef, Out, ReadTxn, Text, TextPrelim, TextRef, Transact, TransactionMut,
9};
10
11use crate::{
12 DeckSession, EditError, EditResult, KIND, PARA_ID, PILCROW_KIND, ParagraphSnapshot, STORIES,
13 StorySnapshot, TextReceipt, TextRunSnapshot, TextStyle, TextStylePatch,
14};
15
16pub(crate) fn seed_story(
17 stories: &MapRef,
18 txn: &mut TransactionMut<'_>,
19 story_id: &str,
20 body: &TextBody,
21 theme: Option<&Theme>,
22) -> EditResult<()> {
23 let story = stories.insert(txn, story_id, TextPrelim::new(""));
24 if body.paragraphs.is_empty() {
25 append_pilcrow(&story, txn, &format!("para:{story_id}:0"), None, 0, None);
26 return Ok(());
27 }
28 for (paragraph_index, paragraph) in body.paragraphs.iter().enumerate() {
29 for run in ¶graph.runs {
30 if !run.text.is_empty() {
31 let style = style_from_run_properties(&run.properties, theme);
32 let index = story.len(txn);
33 insert_styled_text(&story, txn, index, &run.text, &style);
34 }
35 }
36 let bullet_json = paragraph
37 .properties
38 .bullet
39 .as_ref()
40 .map(serde_json::to_string)
41 .transpose()
42 .map_err(|error| EditError::Json(error.to_string()))?;
43 append_pilcrow(
44 &story,
45 txn,
46 &format!("para:{story_id}:{paragraph_index}"),
47 paragraph.properties.alignment.as_deref(),
48 paragraph.properties.level,
49 bullet_json.as_deref(),
50 );
51 }
52 Ok(())
53}
54
55pub(crate) fn seed_plain_story(
56 stories: &MapRef,
57 txn: &mut TransactionMut<'_>,
58 story_id: &str,
59 paragraph_id: &str,
60 text: &str,
61 style: &TextStyle,
62) -> TextRef {
63 let story = stories.insert(txn, story_id, TextPrelim::new(""));
64 if !text.is_empty() {
65 insert_styled_text(&story, txn, 0, text, style);
66 }
67 append_pilcrow(&story, txn, paragraph_id, None, 0, None);
68 story
69}
70
71fn append_pilcrow(
72 story: &TextRef,
73 txn: &mut TransactionMut<'_>,
74 paragraph_id: &str,
75 alignment: Option<&str>,
76 level: u32,
77 bullet_json: Option<&str>,
78) {
79 let index = story.len(txn);
80 let pilcrow =
81 story.insert_embed_with_attributes(txn, index, MapPrelim::default(), Attrs::default());
82 pilcrow.insert(txn, KIND, PILCROW_KIND);
83 pilcrow.insert(txn, PARA_ID, paragraph_id);
84 pilcrow.insert(txn, "level", level as f64);
85 if let Some(alignment) = alignment {
86 pilcrow.insert(txn, "alignment", alignment);
87 }
88 if let Some(bullet_json) = bullet_json {
89 pilcrow.insert(txn, "bulletJson", bullet_json);
90 }
91}
92
93impl DeckSession {
94 pub fn story(&self, story_id: &str) -> EditResult<StorySnapshot> {
95 let txn = self.doc.transact();
96 let story = story_ref(&txn, story_id)?;
97 snapshot_story(&story, &txn, story_id)
98 }
99
100 pub fn insert_text(
101 &self,
102 context: &crate::EditCtx,
103 story_id: &str,
104 index: u32,
105 text: &str,
106 style: &TextStyle,
107 ) -> EditResult<TextReceipt> {
108 let mut txn = self.transact_for(context);
109 let story = story_ref(&txn, story_id)?;
110 let final_pilcrow = final_pilcrow_index(&story, &txn)?;
111 if index > final_pilcrow {
112 return Err(EditError::OutOfBounds {
113 index,
114 length: final_pilcrow,
115 });
116 }
117 if !text.is_empty() {
118 insert_styled_text(&story, &mut txn, index, text, style);
119 }
120 let length = text.encode_utf16().count() as u32;
121 Ok(TextReceipt {
122 story_id: story_id.to_owned(),
123 start: index,
124 end: index + length,
125 text: text.to_owned(),
126 })
127 }
128
129 pub fn delete_text(
130 &self,
131 context: &crate::EditCtx,
132 story_id: &str,
133 start: u32,
134 end: u32,
135 ) -> EditResult<TextReceipt> {
136 let mut txn = self.transact_for(context);
137 let story = story_ref(&txn, story_id)?;
138 check_text_range(&story, &txn, start, end)?;
139 let text = text_in_range(&story, &txn, start, end);
140 if end > start {
141 story.remove_range(&mut txn, start, end - start);
142 }
143 Ok(TextReceipt {
144 story_id: story_id.to_owned(),
145 start,
146 end,
147 text,
148 })
149 }
150
151 pub fn format_text(
152 &self,
153 context: &crate::EditCtx,
154 story_id: &str,
155 start: u32,
156 end: u32,
157 patch: &TextStylePatch,
158 ) -> EditResult<TextReceipt> {
159 let mut txn = self.transact_for(context);
160 let story = story_ref(&txn, story_id)?;
161 check_text_range(&story, &txn, start, end)?;
162 let text = text_in_range(&story, &txn, start, end);
163 if end > start {
164 story.format(&mut txn, start, end - start, attrs_from_patch(patch));
165 }
166 Ok(TextReceipt {
167 story_id: story_id.to_owned(),
168 start,
169 end,
170 text,
171 })
172 }
173
174 pub fn insert_paragraph_break(
175 &self,
176 context: &crate::EditCtx,
177 story_id: &str,
178 index: u32,
179 ) -> EditResult<TextReceipt> {
180 let mut txn = self.transact_for(context);
181 let story = story_ref(&txn, story_id)?;
182 let final_pilcrow = final_pilcrow_index(&story, &txn)?;
183 if index > final_pilcrow {
184 return Err(EditError::OutOfBounds {
185 index,
186 length: final_pilcrow,
187 });
188 }
189 let paragraph_id = self.next_id("para");
190 let pilcrow = story.insert_embed_with_attributes(
191 &mut txn,
192 index,
193 MapPrelim::default(),
194 Attrs::default(),
195 );
196 pilcrow.insert(&mut txn, KIND, PILCROW_KIND);
197 pilcrow.insert(&mut txn, PARA_ID, paragraph_id);
198 pilcrow.insert(&mut txn, "level", 0_f64);
199 Ok(TextReceipt {
200 story_id: story_id.to_owned(),
201 start: index,
202 end: index + 1,
203 text: "\n".to_owned(),
204 })
205 }
206}
207
208pub(crate) fn validate_story<T: ReadTxn>(
209 story: &TextRef,
210 txn: &T,
211 story_id: &str,
212) -> EditResult<()> {
213 let mut offset = 0;
214 let mut pilcrows = Vec::new();
215 for diff in story.diff(txn, YChange::identity) {
216 let length = out_len(&diff.insert);
217 if let Out::YMap(map) = diff.insert {
218 if map_string(&map, txn, KIND).as_deref() != Some(PILCROW_KIND) {
219 return Err(EditError::InvalidState(format!(
220 "story {story_id} contains a non-pilcrow embed"
221 )));
222 }
223 pilcrows.push(offset);
224 }
225 offset += length;
226 }
227 if pilcrows.last().copied() != story.len(txn).checked_sub(1) {
228 return Err(EditError::InvalidState(format!(
229 "story {story_id} has no final pilcrow"
230 )));
231 }
232 Ok(())
233}
234
235pub(crate) fn snapshot_story<T: ReadTxn>(
236 story: &TextRef,
237 txn: &T,
238 story_id: &str,
239) -> EditResult<StorySnapshot> {
240 validate_story(story, txn, story_id)?;
241 let mut paragraphs = Vec::new();
242 let mut runs = Vec::new();
243 for diff in story.diff(txn, YChange::identity) {
244 match diff.insert {
245 Out::Any(Any::String(text)) => runs.push(TextRunSnapshot {
246 text: text.to_string(),
247 style: style_from_attrs(diff.attributes.as_deref()),
248 }),
249 Out::YMap(map) => {
250 paragraphs.push(ParagraphSnapshot {
251 id: map_string(&map, txn, PARA_ID).unwrap_or_default(),
252 alignment: map_string(&map, txn, "alignment"),
253 level: map_number(&map, txn, "level").unwrap_or_default() as u32,
254 bullet_json: map_string(&map, txn, "bulletJson"),
255 runs: std::mem::take(&mut runs),
256 });
257 }
258 _ => {}
259 }
260 }
261 Ok(StorySnapshot {
262 id: story_id.to_owned(),
263 length: story.len(txn),
264 paragraphs,
265 })
266}
267
268fn story_ref<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<TextRef> {
269 txn.get_map(STORIES)
270 .and_then(|stories| stories.get(txn, story_id))
271 .and_then(|value| value.cast::<TextRef>().ok())
272 .ok_or_else(|| EditError::StoryNotFound(story_id.to_owned()))
273}
274
275fn final_pilcrow_index<T: ReadTxn>(story: &TextRef, txn: &T) -> EditResult<u32> {
276 validate_story(story, txn, "requested")?;
277 Ok(story.len(txn) - 1)
278}
279
280fn check_text_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
281 let length = story.len(txn);
282 if start > end || end > length {
283 return Err(EditError::OutOfBounds {
284 index: end.max(start),
285 length,
286 });
287 }
288 let mut offset = 0;
289 for diff in story.diff(txn, YChange::identity) {
290 let item_length = out_len(&diff.insert);
291 if matches!(diff.insert, Out::YMap(_)) && start <= offset && offset < end {
292 return Err(EditError::ParagraphBoundary { start, end });
293 }
294 offset += item_length;
295 }
296 Ok(())
297}
298
299fn text_in_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> String {
300 let mut output = String::new();
301 let mut offset = 0;
302 for diff in story.diff(txn, YChange::identity) {
303 let length = out_len(&diff.insert);
304 if let Out::Any(Any::String(text)) = diff.insert {
305 let overlap_start = start.saturating_sub(offset).min(length);
306 let overlap_end = end.saturating_sub(offset).min(length);
307 if overlap_end > overlap_start {
308 let utf16 = text.encode_utf16().collect::<Vec<_>>();
309 output.push_str(&String::from_utf16_lossy(
310 &utf16[overlap_start as usize..overlap_end as usize],
311 ));
312 }
313 }
314 offset += length;
315 if offset >= end {
316 break;
317 }
318 }
319 output
320}
321
322fn insert_styled_text(
323 story: &TextRef,
324 txn: &mut TransactionMut<'_>,
325 index: u32,
326 text: &str,
327 style: &TextStyle,
328) {
329 story.insert(txn, index, text);
330 let length = text.encode_utf16().count() as u32;
331 for (key, value) in style_values(style) {
332 story.format(txn, index, length, Attrs::from([(Arc::from(key), value)]));
333 }
334}
335
336fn style_values(style: &TextStyle) -> [(&'static str, Any); 6] {
337 [
338 ("bold", style.bold.map(Any::Bool).unwrap_or(Any::Null)),
339 ("italic", style.italic.map(Any::Bool).unwrap_or(Any::Null)),
340 (
341 "fontSize",
342 style.font_size_pt.map(Any::Number).unwrap_or(Any::Null),
343 ),
344 (
345 "color",
346 style.color.as_deref().map(Any::from).unwrap_or(Any::Null),
347 ),
348 (
349 "fontFamily",
350 style
351 .font_family
352 .as_deref()
353 .map(Any::from)
354 .unwrap_or(Any::Null),
355 ),
356 (
357 "underline",
358 style
359 .underline
360 .as_deref()
361 .map(Any::from)
362 .unwrap_or(Any::Null),
363 ),
364 ]
365}
366
367fn attrs_from_patch(patch: &TextStylePatch) -> Attrs {
368 let mut attrs = Attrs::default();
369 insert_option(&mut attrs, "bold", patch.bold.map(Any::Bool));
370 insert_option(&mut attrs, "italic", patch.italic.map(Any::Bool));
371 insert_option(&mut attrs, "fontSize", patch.font_size_pt.map(Any::Number));
372 insert_option(&mut attrs, "color", patch.color.as_deref().map(Any::from));
373 insert_option(
374 &mut attrs,
375 "fontFamily",
376 patch.font_family.as_deref().map(Any::from),
377 );
378 insert_option(
379 &mut attrs,
380 "underline",
381 patch.underline.as_deref().map(Any::from),
382 );
383 attrs
384}
385
386fn insert_option(attrs: &mut Attrs, key: &str, value: Option<Any>) {
387 if let Some(value) = value {
388 attrs.insert(Arc::from(key), value);
389 }
390}
391
392fn style_from_run_properties(properties: &RunProperties, theme: Option<&Theme>) -> TextStyle {
393 TextStyle {
394 bold: properties.bold,
395 italic: properties.italic,
396 font_size_pt: properties.font_size_pt,
397 color: resolve_color_value_to_hex_with_theme(properties.color.as_ref(), theme),
398 font_family: properties.font_family.clone(),
399 underline: properties.underline.clone(),
400 }
401}
402
403fn style_from_attrs(attrs: Option<&Attrs>) -> TextStyle {
404 TextStyle {
405 bold: attrs.and_then(|attrs| any_bool(attrs.get("bold"))),
406 italic: attrs.and_then(|attrs| any_bool(attrs.get("italic"))),
407 font_size_pt: attrs.and_then(|attrs| any_number(attrs.get("fontSize"))),
408 color: attrs.and_then(|attrs| any_string(attrs.get("color"))),
409 font_family: attrs.and_then(|attrs| any_string(attrs.get("fontFamily"))),
410 underline: attrs.and_then(|attrs| any_string(attrs.get("underline"))),
411 }
412}
413
414fn any_bool(value: Option<&Any>) -> Option<bool> {
415 match value {
416 Some(Any::Bool(value)) => Some(*value),
417 _ => None,
418 }
419}
420
421fn any_number(value: Option<&Any>) -> Option<f64> {
422 match value {
423 Some(Any::Number(value)) if value.is_finite() => Some(*value),
424 Some(Any::BigInt(value)) => Some(*value as f64),
425 _ => None,
426 }
427}
428
429fn any_string(value: Option<&Any>) -> Option<String> {
430 match value {
431 Some(Any::String(value)) => Some(value.to_string()),
432 _ => None,
433 }
434}
435
436fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
437 match map.get(txn, key) {
438 Some(Out::Any(Any::String(value))) => Some(value.to_string()),
439 _ => None,
440 }
441}
442
443fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
444 match map.get(txn, key) {
445 Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
446 Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
447 _ => None,
448 }
449}
450
451fn out_len(value: &Out) -> u32 {
452 match value {
453 Out::Any(Any::String(value)) => value.encode_utf16().count() as u32,
454 _ => 1,
455 }
456}