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_bounds(&story, &txn, start, end)?;
162 let text = text_in_range(&story, &txn, start, end);
163 for (segment_start, segment_end) in paragraph_text_segments(&story, &txn, start, end) {
164 story.format(
165 &mut txn,
166 segment_start,
167 segment_end - segment_start,
168 attrs_from_patch(patch),
169 );
170 }
171 Ok(TextReceipt {
172 story_id: story_id.to_owned(),
173 start,
174 end,
175 text,
176 })
177 }
178
179 pub fn insert_paragraph_break(
180 &self,
181 context: &crate::EditCtx,
182 story_id: &str,
183 index: u32,
184 ) -> EditResult<TextReceipt> {
185 let mut txn = self.transact_for(context);
186 let story = story_ref(&txn, story_id)?;
187 let final_pilcrow = final_pilcrow_index(&story, &txn)?;
188 if index > final_pilcrow {
189 return Err(EditError::OutOfBounds {
190 index,
191 length: final_pilcrow,
192 });
193 }
194 let paragraph_id = self.next_id("para");
195 let pilcrow = story.insert_embed_with_attributes(
196 &mut txn,
197 index,
198 MapPrelim::default(),
199 Attrs::default(),
200 );
201 pilcrow.insert(&mut txn, KIND, PILCROW_KIND);
202 pilcrow.insert(&mut txn, PARA_ID, paragraph_id);
203 pilcrow.insert(&mut txn, "level", 0_f64);
204 Ok(TextReceipt {
205 story_id: story_id.to_owned(),
206 start: index,
207 end: index + 1,
208 text: "\n".to_owned(),
209 })
210 }
211}
212
213pub(crate) fn validate_story<T: ReadTxn>(
214 story: &TextRef,
215 txn: &T,
216 story_id: &str,
217) -> EditResult<()> {
218 let mut offset = 0;
219 let mut pilcrows = Vec::new();
220 for diff in story.diff(txn, YChange::identity) {
221 let length = out_len(&diff.insert);
222 if let Out::YMap(map) = diff.insert {
223 if map_string(&map, txn, KIND).as_deref() != Some(PILCROW_KIND) {
224 return Err(EditError::InvalidState(format!(
225 "story {story_id} contains a non-pilcrow embed"
226 )));
227 }
228 pilcrows.push(offset);
229 }
230 offset += length;
231 }
232 if pilcrows.last().copied() != story.len(txn).checked_sub(1) {
233 return Err(EditError::InvalidState(format!(
234 "story {story_id} has no final pilcrow"
235 )));
236 }
237 Ok(())
238}
239
240pub(crate) fn snapshot_story<T: ReadTxn>(
241 story: &TextRef,
242 txn: &T,
243 story_id: &str,
244) -> EditResult<StorySnapshot> {
245 validate_story(story, txn, story_id)?;
246 let mut paragraphs = Vec::new();
247 let mut runs = Vec::new();
248 for diff in story.diff(txn, YChange::identity) {
249 match diff.insert {
250 Out::Any(Any::String(text)) => runs.push(TextRunSnapshot {
251 text: text.to_string(),
252 style: style_from_attrs(diff.attributes.as_deref()),
253 }),
254 Out::YMap(map) => {
255 paragraphs.push(ParagraphSnapshot {
256 id: map_string(&map, txn, PARA_ID).unwrap_or_default(),
257 alignment: map_string(&map, txn, "alignment"),
258 level: map_number(&map, txn, "level").unwrap_or_default() as u32,
259 bullet_json: map_string(&map, txn, "bulletJson"),
260 runs: std::mem::take(&mut runs),
261 });
262 }
263 _ => {}
264 }
265 }
266 Ok(StorySnapshot {
267 id: story_id.to_owned(),
268 length: story.len(txn),
269 paragraphs,
270 })
271}
272
273fn story_ref<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<TextRef> {
274 txn.get_map(STORIES)
275 .and_then(|stories| stories.get(txn, story_id))
276 .and_then(|value| value.cast::<TextRef>().ok())
277 .ok_or_else(|| EditError::StoryNotFound(story_id.to_owned()))
278}
279
280fn final_pilcrow_index<T: ReadTxn>(story: &TextRef, txn: &T) -> EditResult<u32> {
281 validate_story(story, txn, "requested")?;
282 Ok(story.len(txn) - 1)
283}
284
285fn check_text_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
286 check_text_bounds(story, txn, start, end)?;
287 let mut offset = 0;
288 for diff in story.diff(txn, YChange::identity) {
289 let item_length = out_len(&diff.insert);
290 if matches!(diff.insert, Out::YMap(_)) && start <= offset && offset < end {
291 return Err(EditError::ParagraphBoundary { start, end });
292 }
293 offset += item_length;
294 }
295 Ok(())
296}
297
298fn check_text_bounds<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
299 let length = story.len(txn);
300 if start > end || end > length {
301 return Err(EditError::OutOfBounds {
302 index: end.max(start),
303 length,
304 });
305 }
306 Ok(())
307}
308
309fn paragraph_text_segments<T: ReadTxn>(
310 story: &TextRef,
311 txn: &T,
312 start: u32,
313 end: u32,
314) -> Vec<(u32, u32)> {
315 let mut segments = Vec::new();
316 let mut paragraph_start = 0;
317 let mut offset = 0;
318 for diff in story.diff(txn, YChange::identity) {
319 let item_length = out_len(&diff.insert);
320 if matches!(diff.insert, Out::YMap(_)) {
321 let segment_start = start.max(paragraph_start);
322 let segment_end = end.min(offset);
323 if segment_start < segment_end {
324 segments.push((segment_start, segment_end));
325 }
326 paragraph_start = offset + item_length;
327 }
328 offset += item_length;
329 }
330 segments
331}
332
333fn text_in_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> String {
334 let mut output = String::new();
335 let mut offset = 0;
336 for diff in story.diff(txn, YChange::identity) {
337 let length = out_len(&diff.insert);
338 if let Out::Any(Any::String(text)) = diff.insert {
339 let overlap_start = start.saturating_sub(offset).min(length);
340 let overlap_end = end.saturating_sub(offset).min(length);
341 if overlap_end > overlap_start {
342 let utf16 = text.encode_utf16().collect::<Vec<_>>();
343 output.push_str(&String::from_utf16_lossy(
344 &utf16[overlap_start as usize..overlap_end as usize],
345 ));
346 }
347 }
348 offset += length;
349 if offset >= end {
350 break;
351 }
352 }
353 output
354}
355
356fn insert_styled_text(
357 story: &TextRef,
358 txn: &mut TransactionMut<'_>,
359 index: u32,
360 text: &str,
361 style: &TextStyle,
362) {
363 story.insert(txn, index, text);
364 let length = text.encode_utf16().count() as u32;
365 for (key, value) in style_values(style) {
366 story.format(txn, index, length, Attrs::from([(Arc::from(key), value)]));
367 }
368}
369
370fn style_values(style: &TextStyle) -> [(&'static str, Any); 6] {
371 [
372 ("bold", style.bold.map(Any::Bool).unwrap_or(Any::Null)),
373 ("italic", style.italic.map(Any::Bool).unwrap_or(Any::Null)),
374 (
375 "fontSize",
376 style.font_size_pt.map(Any::Number).unwrap_or(Any::Null),
377 ),
378 (
379 "color",
380 style.color.as_deref().map(Any::from).unwrap_or(Any::Null),
381 ),
382 (
383 "fontFamily",
384 style
385 .font_family
386 .as_deref()
387 .map(Any::from)
388 .unwrap_or(Any::Null),
389 ),
390 (
391 "underline",
392 style
393 .underline
394 .as_deref()
395 .map(Any::from)
396 .unwrap_or(Any::Null),
397 ),
398 ]
399}
400
401fn attrs_from_patch(patch: &TextStylePatch) -> Attrs {
402 let mut attrs = Attrs::default();
403 insert_option(&mut attrs, "bold", patch.bold.map(Any::Bool));
404 insert_option(&mut attrs, "italic", patch.italic.map(Any::Bool));
405 insert_option(&mut attrs, "fontSize", patch.font_size_pt.map(Any::Number));
406 insert_option(&mut attrs, "color", patch.color.as_deref().map(Any::from));
407 insert_option(
408 &mut attrs,
409 "fontFamily",
410 patch.font_family.as_deref().map(Any::from),
411 );
412 insert_option(
413 &mut attrs,
414 "underline",
415 patch.underline.as_deref().map(Any::from),
416 );
417 attrs
418}
419
420fn insert_option(attrs: &mut Attrs, key: &str, value: Option<Any>) {
421 if let Some(value) = value {
422 attrs.insert(Arc::from(key), value);
423 }
424}
425
426fn style_from_run_properties(properties: &RunProperties, theme: Option<&Theme>) -> TextStyle {
427 TextStyle {
428 bold: properties.bold,
429 italic: properties.italic,
430 font_size_pt: properties.font_size_pt,
431 color: resolve_color_value_to_hex_with_theme(properties.color.as_ref(), theme),
432 font_family: properties.font_family.clone(),
433 underline: properties.underline.clone(),
434 }
435}
436
437fn style_from_attrs(attrs: Option<&Attrs>) -> TextStyle {
438 TextStyle {
439 bold: attrs.and_then(|attrs| any_bool(attrs.get("bold"))),
440 italic: attrs.and_then(|attrs| any_bool(attrs.get("italic"))),
441 font_size_pt: attrs.and_then(|attrs| any_number(attrs.get("fontSize"))),
442 color: attrs.and_then(|attrs| any_string(attrs.get("color"))),
443 font_family: attrs.and_then(|attrs| any_string(attrs.get("fontFamily"))),
444 underline: attrs.and_then(|attrs| any_string(attrs.get("underline"))),
445 }
446}
447
448fn any_bool(value: Option<&Any>) -> Option<bool> {
449 match value {
450 Some(Any::Bool(value)) => Some(*value),
451 _ => None,
452 }
453}
454
455fn any_number(value: Option<&Any>) -> Option<f64> {
456 match value {
457 Some(Any::Number(value)) if value.is_finite() => Some(*value),
458 Some(Any::BigInt(value)) => Some(*value as f64),
459 _ => None,
460 }
461}
462
463fn any_string(value: Option<&Any>) -> Option<String> {
464 match value {
465 Some(Any::String(value)) => Some(value.to_string()),
466 _ => None,
467 }
468}
469
470fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
471 match map.get(txn, key) {
472 Some(Out::Any(Any::String(value))) => Some(value.to_string()),
473 _ => None,
474 }
475}
476
477fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
478 match map.get(txn, key) {
479 Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
480 Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
481 _ => None,
482 }
483}
484
485fn out_len(value: &Out) -> u32 {
486 match value {
487 Out::Any(Any::String(value)) => value.encode_utf16().count() as u32,
488 _ => 1,
489 }
490}