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::model::validate_xml_text;
12use crate::{
13    DeckSession, EditError, EditResult, KIND, PARA_ID, PILCROW_KIND, ParagraphSnapshot, STORIES,
14    StorySnapshot, TextReceipt, TextRunSnapshot, TextStyle, TextStylePatch,
15};
16
17const UNDERLINE_TYPES: [&str; 18] = [
18    "none",
19    "words",
20    "sng",
21    "dbl",
22    "heavy",
23    "dotted",
24    "dottedHeavy",
25    "dash",
26    "dashHeavy",
27    "dashLong",
28    "dashLongHeavy",
29    "dotDash",
30    "dotDashHeavy",
31    "dotDotDash",
32    "dotDotDashHeavy",
33    "wavy",
34    "wavyHeavy",
35    "wavyDbl",
36];
37
38/// The values land in schema-typed attributes, so junk must fail the edit
39/// rather than the file.
40pub(crate) fn validate_style_values(
41    font_family: Option<&str>,
42    underline: Option<&str>,
43    color: Option<&str>,
44    font_size_pt: Option<f64>,
45) -> EditResult<()> {
46    if let Some(font_family) = font_family {
47        validate_xml_text(font_family)?;
48    }
49    if let Some(underline) = underline
50        && !UNDERLINE_TYPES.contains(&underline)
51    {
52        return Err(EditError::InvalidText(format!(
53            "unrecognized underline type {underline:?}"
54        )));
55    }
56    if let Some(color) = color {
57        let rgb = color.strip_prefix('#').unwrap_or(color);
58        if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
59            return Err(EditError::InvalidText(format!(
60                "color {color:?} must be a six-digit hex value"
61            )));
62        }
63    }
64    if let Some(size) = font_size_pt
65        && (!size.is_finite() || !(1.0..=4_000.0).contains(&size))
66    {
67        return Err(EditError::InvalidText(format!(
68            "font size {size}pt is outside the 1-4000pt range"
69        )));
70    }
71    Ok(())
72}
73
74pub(crate) fn seed_story(
75    stories: &MapRef,
76    txn: &mut TransactionMut<'_>,
77    story_id: &str,
78    body: &TextBody,
79    theme: Option<&Theme>,
80) -> EditResult<()> {
81    let story = stories.insert(txn, story_id, TextPrelim::new(""));
82    if body.paragraphs.is_empty() {
83        append_pilcrow(&story, txn, &format!("para:{story_id}:0"), None, 0, None);
84        return Ok(());
85    }
86    for (paragraph_index, paragraph) in body.paragraphs.iter().enumerate() {
87        for run in &paragraph.runs {
88            if !run.text.is_empty() {
89                let style = style_from_run_properties(&run.properties, theme);
90                let index = story.len(txn);
91                insert_styled_text(&story, txn, index, &run.text, &style);
92            }
93        }
94        let bullet_json = paragraph
95            .properties
96            .bullet
97            .as_ref()
98            .map(serde_json::to_string)
99            .transpose()
100            .map_err(|error| EditError::Json(error.to_string()))?;
101        append_pilcrow(
102            &story,
103            txn,
104            &format!("para:{story_id}:{paragraph_index}"),
105            paragraph.properties.alignment.as_deref(),
106            paragraph.properties.level,
107            bullet_json.as_deref(),
108        );
109    }
110    Ok(())
111}
112
113pub(crate) fn seed_plain_story(
114    stories: &MapRef,
115    txn: &mut TransactionMut<'_>,
116    story_id: &str,
117    paragraph_id: &str,
118    text: &str,
119    style: &TextStyle,
120) -> TextRef {
121    let story = stories.insert(txn, story_id, TextPrelim::new(""));
122    if !text.is_empty() {
123        insert_styled_text(&story, txn, 0, text, style);
124    }
125    append_pilcrow(&story, txn, paragraph_id, None, 0, None);
126    story
127}
128
129fn append_pilcrow(
130    story: &TextRef,
131    txn: &mut TransactionMut<'_>,
132    paragraph_id: &str,
133    alignment: Option<&str>,
134    level: u32,
135    bullet_json: Option<&str>,
136) {
137    let index = story.len(txn);
138    let pilcrow =
139        story.insert_embed_with_attributes(txn, index, MapPrelim::default(), Attrs::default());
140    pilcrow.insert(txn, KIND, PILCROW_KIND);
141    pilcrow.insert(txn, PARA_ID, paragraph_id);
142    pilcrow.insert(txn, "level", level as f64);
143    if let Some(alignment) = alignment {
144        pilcrow.insert(txn, "alignment", alignment);
145    }
146    if let Some(bullet_json) = bullet_json {
147        pilcrow.insert(txn, "bulletJson", bullet_json);
148    }
149}
150
151impl DeckSession {
152    pub fn story(&self, story_id: &str) -> EditResult<StorySnapshot> {
153        let txn = self.doc.transact();
154        let story = story_ref(&txn, story_id)?;
155        snapshot_story(&story, &txn, story_id)
156    }
157
158    pub fn insert_text(
159        &self,
160        context: &crate::EditCtx,
161        story_id: &str,
162        index: u32,
163        text: &str,
164        style: &TextStyle,
165    ) -> EditResult<TextReceipt> {
166        validate_xml_text(text)?;
167        validate_style_values(
168            style.font_family.as_deref(),
169            style.underline.as_deref(),
170            style.color.as_deref(),
171            style.font_size_pt,
172        )?;
173        let mut txn = self.transact_for(context);
174        let story = story_ref(&txn, story_id)?;
175        let final_pilcrow = final_pilcrow_index(&story, &txn)?;
176        if index > final_pilcrow {
177            return Err(EditError::OutOfBounds {
178                index,
179                length: final_pilcrow,
180            });
181        }
182        if !text.is_empty() {
183            insert_styled_text(&story, &mut txn, index, text, style);
184        }
185        let length = text.encode_utf16().count() as u32;
186        Ok(TextReceipt {
187            story_id: story_id.to_owned(),
188            start: index,
189            end: index + length,
190            text: text.to_owned(),
191        })
192    }
193
194    pub fn delete_text(
195        &self,
196        context: &crate::EditCtx,
197        story_id: &str,
198        start: u32,
199        end: u32,
200    ) -> EditResult<TextReceipt> {
201        let mut txn = self.transact_for(context);
202        let story = story_ref(&txn, story_id)?;
203        check_text_range(&story, &txn, start, end)?;
204        let text = text_in_range(&story, &txn, start, end);
205        if end > start {
206            story.remove_range(&mut txn, start, end - start);
207        }
208        Ok(TextReceipt {
209            story_id: story_id.to_owned(),
210            start,
211            end,
212            text,
213        })
214    }
215
216    pub fn format_text(
217        &self,
218        context: &crate::EditCtx,
219        story_id: &str,
220        start: u32,
221        end: u32,
222        patch: &TextStylePatch,
223    ) -> EditResult<TextReceipt> {
224        validate_style_values(
225            patch.font_family.as_deref(),
226            patch.underline.as_deref(),
227            patch.color.as_deref(),
228            patch.font_size_pt,
229        )?;
230        let mut txn = self.transact_for(context);
231        let story = story_ref(&txn, story_id)?;
232        check_text_bounds(&story, &txn, start, end)?;
233        let text = text_in_range(&story, &txn, start, end);
234        for (segment_start, segment_end) in paragraph_text_segments(&story, &txn, start, end) {
235            story.format(
236                &mut txn,
237                segment_start,
238                segment_end - segment_start,
239                attrs_from_patch(patch),
240            );
241        }
242        Ok(TextReceipt {
243            story_id: story_id.to_owned(),
244            start,
245            end,
246            text,
247        })
248    }
249
250    pub fn insert_paragraph_break(
251        &self,
252        context: &crate::EditCtx,
253        story_id: &str,
254        index: u32,
255    ) -> EditResult<TextReceipt> {
256        let mut txn = self.transact_for(context);
257        let story = story_ref(&txn, story_id)?;
258        let final_pilcrow = final_pilcrow_index(&story, &txn)?;
259        if index > final_pilcrow {
260            return Err(EditError::OutOfBounds {
261                index,
262                length: final_pilcrow,
263            });
264        }
265        let paragraph_id = self.next_id("para");
266        let pilcrow = story.insert_embed_with_attributes(
267            &mut txn,
268            index,
269            MapPrelim::default(),
270            Attrs::default(),
271        );
272        pilcrow.insert(&mut txn, KIND, PILCROW_KIND);
273        pilcrow.insert(&mut txn, PARA_ID, paragraph_id);
274        pilcrow.insert(&mut txn, "level", 0_f64);
275        Ok(TextReceipt {
276            story_id: story_id.to_owned(),
277            start: index,
278            end: index + 1,
279            text: "\n".to_owned(),
280        })
281    }
282}
283
284pub(crate) fn validate_story<T: ReadTxn>(
285    story: &TextRef,
286    txn: &T,
287    story_id: &str,
288) -> EditResult<()> {
289    let mut offset = 0;
290    let mut pilcrows = Vec::new();
291    for diff in story.diff(txn, YChange::identity) {
292        let length = out_len(&diff.insert);
293        if let Out::YMap(map) = diff.insert {
294            if map_string(&map, txn, KIND).as_deref() != Some(PILCROW_KIND) {
295                return Err(EditError::InvalidState(format!(
296                    "story {story_id} contains a non-pilcrow embed"
297                )));
298            }
299            pilcrows.push(offset);
300        }
301        offset += length;
302    }
303    if pilcrows.last().copied() != story.len(txn).checked_sub(1) {
304        return Err(EditError::InvalidState(format!(
305            "story {story_id} has no final pilcrow"
306        )));
307    }
308    Ok(())
309}
310
311pub(crate) fn snapshot_story<T: ReadTxn>(
312    story: &TextRef,
313    txn: &T,
314    story_id: &str,
315) -> EditResult<StorySnapshot> {
316    validate_story(story, txn, story_id)?;
317    let mut paragraphs = Vec::new();
318    let mut runs = Vec::new();
319    for diff in story.diff(txn, YChange::identity) {
320        match diff.insert {
321            Out::Any(Any::String(text)) => runs.push(TextRunSnapshot {
322                text: text.to_string(),
323                style: style_from_attrs(diff.attributes.as_deref()),
324            }),
325            Out::YMap(map) => {
326                paragraphs.push(ParagraphSnapshot {
327                    id: map_string(&map, txn, PARA_ID).unwrap_or_default(),
328                    alignment: map_string(&map, txn, "alignment"),
329                    level: map_number(&map, txn, "level").unwrap_or_default() as u32,
330                    bullet_json: map_string(&map, txn, "bulletJson"),
331                    runs: std::mem::take(&mut runs),
332                });
333            }
334            _ => {}
335        }
336    }
337    Ok(StorySnapshot {
338        id: story_id.to_owned(),
339        length: story.len(txn),
340        paragraphs,
341    })
342}
343
344fn story_ref<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<TextRef> {
345    txn.get_map(STORIES)
346        .and_then(|stories| stories.get(txn, story_id))
347        .and_then(|value| value.cast::<TextRef>().ok())
348        .ok_or_else(|| EditError::StoryNotFound(story_id.to_owned()))
349}
350
351fn final_pilcrow_index<T: ReadTxn>(story: &TextRef, txn: &T) -> EditResult<u32> {
352    validate_story(story, txn, "requested")?;
353    Ok(story.len(txn) - 1)
354}
355
356fn check_text_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
357    check_text_bounds(story, txn, start, end)?;
358    let mut offset = 0;
359    for diff in story.diff(txn, YChange::identity) {
360        let item_length = out_len(&diff.insert);
361        if matches!(diff.insert, Out::YMap(_)) && start <= offset && offset < end {
362            return Err(EditError::ParagraphBoundary { start, end });
363        }
364        offset += item_length;
365    }
366    Ok(())
367}
368
369fn check_text_bounds<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
370    let length = story.len(txn);
371    if start > end || end > length {
372        return Err(EditError::OutOfBounds {
373            index: end.max(start),
374            length,
375        });
376    }
377    Ok(())
378}
379
380fn paragraph_text_segments<T: ReadTxn>(
381    story: &TextRef,
382    txn: &T,
383    start: u32,
384    end: u32,
385) -> Vec<(u32, u32)> {
386    let mut segments = Vec::new();
387    let mut paragraph_start = 0;
388    let mut offset = 0;
389    for diff in story.diff(txn, YChange::identity) {
390        let item_length = out_len(&diff.insert);
391        if matches!(diff.insert, Out::YMap(_)) {
392            let segment_start = start.max(paragraph_start);
393            let segment_end = end.min(offset);
394            if segment_start < segment_end {
395                segments.push((segment_start, segment_end));
396            }
397            paragraph_start = offset + item_length;
398        }
399        offset += item_length;
400    }
401    segments
402}
403
404fn text_in_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> String {
405    let mut output = String::new();
406    let mut offset = 0;
407    for diff in story.diff(txn, YChange::identity) {
408        let length = out_len(&diff.insert);
409        if let Out::Any(Any::String(text)) = diff.insert {
410            let overlap_start = start.saturating_sub(offset).min(length);
411            let overlap_end = end.saturating_sub(offset).min(length);
412            if overlap_end > overlap_start {
413                let utf16 = text.encode_utf16().collect::<Vec<_>>();
414                output.push_str(&String::from_utf16_lossy(
415                    &utf16[overlap_start as usize..overlap_end as usize],
416                ));
417            }
418        }
419        offset += length;
420        if offset >= end {
421            break;
422        }
423    }
424    output
425}
426
427fn insert_styled_text(
428    story: &TextRef,
429    txn: &mut TransactionMut<'_>,
430    index: u32,
431    text: &str,
432    style: &TextStyle,
433) {
434    story.insert(txn, index, text);
435    let length = text.encode_utf16().count() as u32;
436    for (key, value) in style_values(style) {
437        story.format(txn, index, length, Attrs::from([(Arc::from(key), value)]));
438    }
439}
440
441fn style_values(style: &TextStyle) -> [(&'static str, Any); 6] {
442    [
443        ("bold", style.bold.map(Any::Bool).unwrap_or(Any::Null)),
444        ("italic", style.italic.map(Any::Bool).unwrap_or(Any::Null)),
445        (
446            "fontSize",
447            style.font_size_pt.map(Any::Number).unwrap_or(Any::Null),
448        ),
449        (
450            "color",
451            style.color.as_deref().map(Any::from).unwrap_or(Any::Null),
452        ),
453        (
454            "fontFamily",
455            style
456                .font_family
457                .as_deref()
458                .map(Any::from)
459                .unwrap_or(Any::Null),
460        ),
461        (
462            "underline",
463            style
464                .underline
465                .as_deref()
466                .map(Any::from)
467                .unwrap_or(Any::Null),
468        ),
469    ]
470}
471
472fn attrs_from_patch(patch: &TextStylePatch) -> Attrs {
473    let mut attrs = Attrs::default();
474    insert_option(&mut attrs, "bold", patch.bold.map(Any::Bool));
475    insert_option(&mut attrs, "italic", patch.italic.map(Any::Bool));
476    insert_option(&mut attrs, "fontSize", patch.font_size_pt.map(Any::Number));
477    insert_option(&mut attrs, "color", patch.color.as_deref().map(Any::from));
478    insert_option(
479        &mut attrs,
480        "fontFamily",
481        patch.font_family.as_deref().map(Any::from),
482    );
483    insert_option(
484        &mut attrs,
485        "underline",
486        patch.underline.as_deref().map(Any::from),
487    );
488    attrs
489}
490
491fn insert_option(attrs: &mut Attrs, key: &str, value: Option<Any>) {
492    if let Some(value) = value {
493        attrs.insert(Arc::from(key), value);
494    }
495}
496
497fn style_from_run_properties(properties: &RunProperties, theme: Option<&Theme>) -> TextStyle {
498    TextStyle {
499        bold: properties.bold,
500        italic: properties.italic,
501        font_size_pt: properties.font_size_pt,
502        color: resolve_color_value_to_hex_with_theme(properties.color.as_ref(), theme),
503        font_family: properties.font_family.clone(),
504        underline: properties.underline.clone(),
505    }
506}
507
508fn style_from_attrs(attrs: Option<&Attrs>) -> TextStyle {
509    TextStyle {
510        bold: attrs.and_then(|attrs| any_bool(attrs.get("bold"))),
511        italic: attrs.and_then(|attrs| any_bool(attrs.get("italic"))),
512        font_size_pt: attrs.and_then(|attrs| any_number(attrs.get("fontSize"))),
513        color: attrs.and_then(|attrs| any_string(attrs.get("color"))),
514        font_family: attrs.and_then(|attrs| any_string(attrs.get("fontFamily"))),
515        underline: attrs.and_then(|attrs| any_string(attrs.get("underline"))),
516    }
517}
518
519fn any_bool(value: Option<&Any>) -> Option<bool> {
520    match value {
521        Some(Any::Bool(value)) => Some(*value),
522        _ => None,
523    }
524}
525
526fn any_number(value: Option<&Any>) -> Option<f64> {
527    match value {
528        Some(Any::Number(value)) if value.is_finite() => Some(*value),
529        Some(Any::BigInt(value)) => Some(*value as f64),
530        _ => None,
531    }
532}
533
534fn any_string(value: Option<&Any>) -> Option<String> {
535    match value {
536        Some(Any::String(value)) => Some(value.to_string()),
537        _ => None,
538    }
539}
540
541fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
542    match map.get(txn, key) {
543        Some(Out::Any(Any::String(value))) => Some(value.to_string()),
544        _ => None,
545    }
546}
547
548fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
549    match map.get(txn, key) {
550        Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
551        Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
552        _ => None,
553    }
554}
555
556fn out_len(value: &Out) -> u32 {
557    match value {
558        Out::Any(Any::String(value)) => value.encode_utf16().count() as u32,
559        _ => 1,
560    }
561}