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
38pub(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 ¶graph.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}