Skip to main content

pptx_edit/
story.rs

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 &paragraph.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}