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_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}