1use std::sync::Arc;
2
3use ooxml_drawingml::{Theme, resolve_color_value_to_hex_with_theme};
4use pptx_parse::{RunProperties, TextBody};
5use yrs::branch::{Branch, BranchPtr};
6use yrs::types::Attrs;
7use yrs::types::text::YChange;
8use yrs::{
9 Any, Assoc, IndexedSequence, Map, MapPrelim, MapRef, Out, ReadTxn, StickyIndex, Text,
10 TextPrelim, TextRef, Transact, TransactionMut,
11};
12
13use crate::model::validate_xml_text;
14use crate::{
15 CaretAnchor, DeckSession, EditError, EditResult, KIND, PARA_ID, PILCROW_KIND,
16 ParagraphSnapshot, STORIES, StorySnapshot, TextCaps, TextReceipt, TextRunSnapshot, TextStyle,
17 TextStylePatch,
18};
19
20const UNDERLINE_TYPES: [&str; 18] = [
21 "none",
22 "words",
23 "sng",
24 "dbl",
25 "heavy",
26 "dotted",
27 "dottedHeavy",
28 "dash",
29 "dashHeavy",
30 "dashLong",
31 "dashLongHeavy",
32 "dotDash",
33 "dotDashHeavy",
34 "dotDotDash",
35 "dotDotDashHeavy",
36 "wavy",
37 "wavyHeavy",
38 "wavyDbl",
39];
40
41const ALIGNMENTS: [&str; 7] = ["l", "ctr", "r", "just", "justLow", "dist", "thaiDist"];
42
43pub(crate) fn validate_style_values(
46 font_family: Option<&str>,
47 underline: Option<&str>,
48 color: Option<&str>,
49 font_size_pt: Option<f64>,
50 spacing_pt: Option<f64>,
51 baseline_pct: Option<f64>,
52) -> EditResult<()> {
53 if let Some(font_family) = font_family {
54 validate_xml_text(font_family)?;
55 }
56 if let Some(underline) = underline
57 && !UNDERLINE_TYPES.contains(&underline)
58 {
59 return Err(EditError::InvalidText(format!(
60 "unrecognized underline type {underline:?}"
61 )));
62 }
63 if let Some(color) = color {
64 let rgb = color.strip_prefix('#').unwrap_or(color);
65 if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
66 return Err(EditError::InvalidText(format!(
67 "color {color:?} must be a six-digit hex value"
68 )));
69 }
70 }
71 if let Some(size) = font_size_pt
72 && (!size.is_finite() || !(1.0..=4_000.0).contains(&size))
73 {
74 return Err(EditError::InvalidText(format!(
75 "font size {size}pt is outside the 1-4000pt range"
76 )));
77 }
78 if let Some(spacing) = spacing_pt
79 && (!spacing.is_finite() || !(-4_000.0..=4_000.0).contains(&spacing))
80 {
81 return Err(EditError::InvalidText(format!(
82 "letter spacing {spacing}pt is outside the -4000-4000pt range"
83 )));
84 }
85 if let Some(baseline) = baseline_pct
86 && (!baseline.is_finite()
87 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&(baseline * 1000.0)))
88 {
89 return Err(EditError::InvalidText(
90 "baseline exceeds the signed percentage range".into(),
91 ));
92 }
93 Ok(())
94}
95
96pub(crate) fn validate_alignment(alignment: Option<&str>) -> EditResult<()> {
97 if let Some(alignment) = alignment
98 && !ALIGNMENTS.contains(&alignment)
99 {
100 return Err(EditError::InvalidText(format!(
101 "unrecognized paragraph alignment {alignment:?}"
102 )));
103 }
104 Ok(())
105}
106
107pub(crate) fn seed_story(
108 stories: &MapRef,
109 txn: &mut TransactionMut<'_>,
110 story_id: &str,
111 body: &TextBody,
112 theme: Option<&Theme>,
113) -> EditResult<()> {
114 let story = stories.insert(txn, story_id, TextPrelim::new(""));
115 if body.paragraphs.is_empty() {
116 append_pilcrow(&story, txn, &format!("para:{story_id}:0"), None, 0, None);
117 return Ok(());
118 }
119 for (paragraph_index, paragraph) in body.paragraphs.iter().enumerate() {
120 for run in ¶graph.runs {
121 if !run.text.is_empty() {
122 let style = style_from_run_properties(&run.properties, theme);
123 let index = story.len(txn);
124 insert_styled_text(&story, txn, index, &run.text, &style);
125 }
126 }
127 let bullet_json = paragraph
128 .properties
129 .bullet
130 .as_ref()
131 .map(serde_json::to_string)
132 .transpose()
133 .map_err(|error| EditError::Json(error.to_string()))?;
134 append_pilcrow(
135 &story,
136 txn,
137 &format!("para:{story_id}:{paragraph_index}"),
138 paragraph.properties.alignment.as_deref(),
139 paragraph.properties.level,
140 bullet_json.as_deref(),
141 );
142 }
143 Ok(())
144}
145
146pub(crate) fn seed_plain_story(
147 stories: &MapRef,
148 txn: &mut TransactionMut<'_>,
149 story_id: &str,
150 paragraph_id: &str,
151 text: &str,
152 style: &TextStyle,
153) -> TextRef {
154 let story = stories.insert(txn, story_id, TextPrelim::new(""));
155 if !text.is_empty() {
156 insert_styled_text(&story, txn, 0, text, style);
157 }
158 append_pilcrow(&story, txn, paragraph_id, None, 0, None);
159 story
160}
161
162pub(crate) fn import_source_numbering_restarts(
163 doc: &yrs::Doc,
164 package: &pptx_parse::PptxPackage,
165) -> EditResult<()> {
166 let source = crate::doc_with_client_id(crate::BOOTSTRAP_CLIENT_ID);
167 crate::deck::seed_doc(&source, package, "")?;
168 let source_txn = source.transact();
169 let source_stories = crate::deck::required_map(&source_txn, STORIES)?;
170 let mut restarts = std::collections::HashMap::new();
171 for (_, value) in source_stories.iter(&source_txn) {
172 let Out::YText(story) = value else { continue };
173 for diff in story.diff(&source_txn, YChange::identity) {
174 let Out::YMap(map) = diff.insert else {
175 continue;
176 };
177 let (Some(id), Some(json)) = (
178 map_string(&map, &source_txn, PARA_ID),
179 map_string(&map, &source_txn, "bulletJson"),
180 ) else {
181 continue;
182 };
183 let mut bullet: pptx_parse::Bullet =
184 serde_json::from_str(&json).map_err(|error| EditError::Json(error.to_string()))?;
185 if let pptx_parse::Bullet::AutoNumber {
186 restart: restart @ true,
187 ..
188 } = &mut bullet
189 {
190 *restart = false;
191 restarts.insert(id, (bullet, json));
192 }
193 }
194 }
195 if restarts.is_empty() {
196 return Ok(());
197 }
198 let mut txn = doc.transact_mut_with(crate::MIGRATE_ORIGIN);
199 let stories = crate::deck::required_map(&txn, STORIES)?;
200 let mut updates = Vec::new();
201 for (_, value) in stories.iter(&txn) {
202 let Out::YText(story) = value else { continue };
203 for diff in story.diff(&txn, YChange::identity) {
204 let Out::YMap(map) = diff.insert else {
205 continue;
206 };
207 let Some(id) = map_string(&map, &txn, PARA_ID) else {
208 continue;
209 };
210 let Some((legacy, source)) = restarts.get(&id) else {
211 continue;
212 };
213 let current = map_string(&map, &txn, "bulletJson")
214 .and_then(|json| serde_json::from_str::<pptx_parse::Bullet>(&json).ok());
215 if current.as_ref() == Some(legacy) {
216 updates.push((map, source.clone()));
217 }
218 }
219 }
220 for (map, json) in updates {
221 map.insert(&mut txn, "bulletJson", json);
222 }
223 Ok(())
224}
225
226fn append_pilcrow(
227 story: &TextRef,
228 txn: &mut TransactionMut<'_>,
229 paragraph_id: &str,
230 alignment: Option<&str>,
231 level: u32,
232 bullet_json: Option<&str>,
233) {
234 let index = story.len(txn);
235 let pilcrow =
236 story.insert_embed_with_attributes(txn, index, MapPrelim::default(), Attrs::default());
237 pilcrow.insert(txn, KIND, PILCROW_KIND);
238 pilcrow.insert(txn, PARA_ID, paragraph_id);
239 pilcrow.insert(txn, "level", level as f64);
240 if let Some(alignment) = alignment {
241 pilcrow.insert(txn, "alignment", alignment);
242 }
243 if let Some(bullet_json) = bullet_json {
244 pilcrow.insert(txn, "bulletJson", bullet_json);
245 }
246}
247
248impl DeckSession {
249 pub fn story(&self, story_id: &str) -> EditResult<StorySnapshot> {
250 let txn = self.doc.transact();
251 let story = story_ref(&txn, story_id)?;
252 snapshot_story(&story, &txn, story_id)
253 }
254
255 pub fn anchor_caret(&self, story_id: &str, index: u32) -> EditResult<CaretAnchor> {
256 let txn = self.doc.transact();
257 let story = story_ref(&txn, story_id)?;
258 let length = final_pilcrow_index(&story, &txn)?;
259 if index > length {
260 return Err(EditError::OutOfBounds { index, length });
261 }
262 let position = if index == 0 {
263 StickyIndex::from_type(&txn, &story, Assoc::Before)
264 } else {
265 story
266 .sticky_index(&txn, index, Assoc::After)
267 .ok_or(EditError::OutOfBounds { index, length })?
268 };
269 Ok(CaretAnchor {
270 story_id: story_id.to_owned(),
271 position,
272 })
273 }
274
275 pub fn resolve_caret_anchor(&self, anchor: &CaretAnchor) -> Option<u32> {
276 let txn = self.doc.transact();
277 let story = story_ref(&txn, &anchor.story_id).ok()?;
278 let offset = anchor.position.get_offset(&txn)?;
279 let expected = BranchPtr::from(<TextRef as AsRef<Branch>>::as_ref(&story));
280 if offset.branch != expected {
281 return None;
282 }
283 let length = final_pilcrow_index(&story, &txn).ok()?;
284 Some(offset.index.min(length))
285 }
286
287 pub fn insert_text(
288 &self,
289 context: &crate::EditCtx,
290 story_id: &str,
291 index: u32,
292 text: &str,
293 style: &TextStyle,
294 ) -> EditResult<TextReceipt> {
295 validate_xml_text(text)?;
296 validate_style_values(
297 style.font_family.as_deref(),
298 style.underline.as_deref(),
299 style.color.as_deref(),
300 style.font_size_pt,
301 style.spacing_pt,
302 style.baseline_pct,
303 )?;
304 let mut txn = self.transact_for(context);
305 let story = story_ref(&txn, story_id)?;
306 let final_pilcrow = final_pilcrow_index(&story, &txn)?;
307 if index > final_pilcrow {
308 return Err(EditError::OutOfBounds {
309 index,
310 length: final_pilcrow,
311 });
312 }
313 if !text.is_empty() {
314 insert_styled_text(&story, &mut txn, index, text, style);
315 }
316 let length = text.encode_utf16().count() as u32;
317 Ok(TextReceipt {
318 story_id: story_id.to_owned(),
319 start: index,
320 end: index + length,
321 text: text.to_owned(),
322 })
323 }
324
325 pub fn delete_text(
326 &self,
327 context: &crate::EditCtx,
328 story_id: &str,
329 start: u32,
330 end: u32,
331 ) -> EditResult<TextReceipt> {
332 let mut txn = self.transact_for(context);
333 let story = story_ref(&txn, story_id)?;
334 check_text_range(&story, &txn, start, end)?;
335 let text = text_in_range(&story, &txn, start, end);
336 if end > start {
337 story.remove_range(&mut txn, start, end - start);
338 }
339 Ok(TextReceipt {
340 story_id: story_id.to_owned(),
341 start,
342 end,
343 text,
344 })
345 }
346
347 pub fn format_text(
348 &self,
349 context: &crate::EditCtx,
350 story_id: &str,
351 start: u32,
352 end: u32,
353 patch: &TextStylePatch,
354 ) -> EditResult<TextReceipt> {
355 validate_style_values(
356 patch.font_family.as_deref(),
357 patch.underline.as_deref(),
358 patch.color.as_deref(),
359 patch.font_size_pt,
360 patch.spacing_pt,
361 patch.baseline_pct,
362 )?;
363 let mut txn = self.transact_for(context);
364 let story = story_ref(&txn, story_id)?;
365 check_text_bounds(&story, &txn, start, end)?;
366 let text = text_in_range(&story, &txn, start, end);
367 for (segment_start, segment_end) in paragraph_text_segments(&story, &txn, start, end) {
368 story.format(
369 &mut txn,
370 segment_start,
371 segment_end - segment_start,
372 attrs_from_patch(patch),
373 );
374 }
375 Ok(TextReceipt {
376 story_id: story_id.to_owned(),
377 start,
378 end,
379 text,
380 })
381 }
382
383 pub fn set_paragraph_alignment(
386 &self,
387 context: &crate::EditCtx,
388 story_id: &str,
389 start: u32,
390 end: u32,
391 alignment: Option<&str>,
392 ) -> EditResult<TextReceipt> {
393 validate_alignment(alignment)?;
394 let mut txn = self.transact_for(context);
395 let story = story_ref(&txn, story_id)?;
396 check_text_bounds(&story, &txn, start, end)?;
397 let text = text_in_range(&story, &txn, start, end);
398 let pilcrows = selected_pilcrows(&story, &txn, start, end);
399 for pilcrow in pilcrows {
400 match alignment {
401 Some(alignment) => {
402 pilcrow.insert(&mut txn, "alignment", alignment);
403 }
404 None => {
405 pilcrow.remove(&mut txn, "alignment");
406 }
407 }
408 }
409 Ok(TextReceipt {
410 story_id: story_id.to_owned(),
411 start,
412 end,
413 text,
414 })
415 }
416
417 pub fn insert_paragraph_break(
418 &self,
419 context: &crate::EditCtx,
420 story_id: &str,
421 index: u32,
422 ) -> EditResult<TextReceipt> {
423 let mut txn = self.transact_for(context);
424 let story = story_ref(&txn, story_id)?;
425 let final_pilcrow = final_pilcrow_index(&story, &txn)?;
426 if index > final_pilcrow {
427 return Err(EditError::OutOfBounds {
428 index,
429 length: final_pilcrow,
430 });
431 }
432 let paragraph_id = self.next_id("para");
433 let pilcrow = story.insert_embed_with_attributes(
434 &mut txn,
435 index,
436 MapPrelim::default(),
437 Attrs::default(),
438 );
439 pilcrow.insert(&mut txn, KIND, PILCROW_KIND);
440 pilcrow.insert(&mut txn, PARA_ID, paragraph_id);
441 pilcrow.insert(&mut txn, "level", 0_f64);
442 Ok(TextReceipt {
443 story_id: story_id.to_owned(),
444 start: index,
445 end: index + 1,
446 text: "\n".to_owned(),
447 })
448 }
449
450 pub fn delete_paragraph_break(
451 &self,
452 context: &crate::EditCtx,
453 story_id: &str,
454 index: u32,
455 ) -> EditResult<TextReceipt> {
456 let mut txn = self.transact_for(context);
457 let story = story_ref(&txn, story_id)?;
458 let final_pilcrow = final_pilcrow_index(&story, &txn)?;
459 if index >= final_pilcrow {
460 return Err(EditError::OutOfBounds {
461 index,
462 length: final_pilcrow,
463 });
464 }
465 let mut offset = 0;
466 let is_pilcrow = story.diff(&txn, YChange::identity).into_iter().any(|diff| {
467 let length = out_len(&diff.insert);
468 let found = offset == index
469 && matches!(
470 diff.insert,
471 Out::YMap(ref map)
472 if map_string(map, &txn, KIND).as_deref() == Some(PILCROW_KIND)
473 );
474 offset += length;
475 found
476 });
477 if !is_pilcrow {
478 return Err(EditError::ParagraphBoundary {
479 start: index,
480 end: index.saturating_add(1),
481 });
482 }
483 story.remove_range(&mut txn, index, 1);
484 Ok(TextReceipt {
485 story_id: story_id.to_owned(),
486 start: index,
487 end: index + 1,
488 text: "\n".to_owned(),
489 })
490 }
491}
492
493pub(crate) fn validate_story<T: ReadTxn>(
494 story: &TextRef,
495 txn: &T,
496 story_id: &str,
497) -> EditResult<()> {
498 let mut offset = 0;
499 let mut pilcrows = Vec::new();
500 for diff in story.diff(txn, YChange::identity) {
501 let length = out_len(&diff.insert);
502 if let Out::YMap(map) = diff.insert {
503 if map_string(&map, txn, KIND).as_deref() != Some(PILCROW_KIND) {
504 return Err(EditError::InvalidState(format!(
505 "story {story_id} contains a non-pilcrow embed"
506 )));
507 }
508 pilcrows.push(offset);
509 }
510 offset += length;
511 }
512 if pilcrows.last().copied() != story.len(txn).checked_sub(1) {
513 return Err(EditError::InvalidState(format!(
514 "story {story_id} has no final pilcrow"
515 )));
516 }
517 Ok(())
518}
519
520pub(crate) fn baseline_story(
523 story_id: &str,
524 body: &TextBody,
525 theme: Option<&Theme>,
526) -> EditResult<StorySnapshot> {
527 let mut paragraphs = Vec::with_capacity(body.paragraphs.len().max(1));
528 let mut length = 1_u32;
529 if body.paragraphs.is_empty() {
530 paragraphs.push(ParagraphSnapshot {
531 id: format!("para:{story_id}:0"),
532 alignment: None,
533 level: 0,
534 bullet_json: None,
535 runs: Vec::new(),
536 });
537 } else {
538 length = 0;
539 for (paragraph_index, paragraph) in body.paragraphs.iter().enumerate() {
540 let mut runs: Vec<TextRunSnapshot> = Vec::new();
541 let mut last_attrs: Option<TextStyle> = None;
542 for run in ¶graph.runs {
543 if run.text.is_empty() {
544 continue;
545 }
546 let style = style_from_run_properties(&run.properties, theme);
547 if last_attrs.as_ref() == Some(&style) {
551 if let Some(last) = runs.last_mut() {
552 last.text.push_str(&run.text);
553 }
554 } else {
555 runs.push(TextRunSnapshot {
556 text: run.text.clone(),
557 style: baseline_style(style.clone()),
558 });
559 }
560 last_attrs = Some(style);
561 length += run.text.encode_utf16().count() as u32;
562 }
563 length += 1;
564 let bullet_json = paragraph
565 .properties
566 .bullet
567 .as_ref()
568 .map(serde_json::to_string)
569 .transpose()
570 .map_err(|error| EditError::Json(error.to_string()))?;
571 paragraphs.push(ParagraphSnapshot {
572 id: format!("para:{story_id}:{paragraph_index}"),
573 alignment: paragraph.properties.alignment.clone(),
574 level: paragraph.properties.level,
575 bullet_json,
576 runs,
577 });
578 }
579 }
580 Ok(StorySnapshot {
581 id: story_id.to_owned(),
582 length,
583 paragraphs,
584 })
585}
586
587fn baseline_style(style: TextStyle) -> TextStyle {
590 TextStyle {
591 font_size_pt: style.font_size_pt.filter(|value| value.is_finite()),
592 spacing_pt: style.spacing_pt.filter(|value| value.is_finite()),
593 baseline_pct: style.baseline_pct.filter(|value| value.is_finite()),
594 ..style
595 }
596}
597
598pub(crate) fn snapshot_story<T: ReadTxn>(
599 story: &TextRef,
600 txn: &T,
601 story_id: &str,
602) -> EditResult<StorySnapshot> {
603 validate_story(story, txn, story_id)?;
604 let mut paragraphs = Vec::new();
605 let mut runs = Vec::new();
606 for diff in story.diff(txn, YChange::identity) {
607 match diff.insert {
608 Out::Any(Any::String(text)) => runs.push(TextRunSnapshot {
609 text: text.to_string(),
610 style: style_from_attrs(diff.attributes.as_deref()),
611 }),
612 Out::YMap(map) => {
613 paragraphs.push(ParagraphSnapshot {
614 id: map_string(&map, txn, PARA_ID).unwrap_or_default(),
615 alignment: map_string(&map, txn, "alignment"),
616 level: map_number(&map, txn, "level").unwrap_or_default() as u32,
617 bullet_json: map_string(&map, txn, "bulletJson"),
618 runs: std::mem::take(&mut runs),
619 });
620 }
621 _ => {}
622 }
623 }
624 Ok(StorySnapshot {
625 id: story_id.to_owned(),
626 length: story.len(txn),
627 paragraphs,
628 })
629}
630
631pub(crate) fn story_ref<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<TextRef> {
632 txn.get_map(STORIES)
633 .and_then(|stories| stories.get(txn, story_id))
634 .and_then(|value| value.cast::<TextRef>().ok())
635 .ok_or_else(|| EditError::StoryNotFound(story_id.to_owned()))
636}
637
638fn final_pilcrow_index<T: ReadTxn>(story: &TextRef, txn: &T) -> EditResult<u32> {
639 validate_story(story, txn, "requested")?;
640 Ok(story.len(txn) - 1)
641}
642
643fn check_text_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
644 check_text_bounds(story, txn, start, end)?;
645 let mut offset = 0;
646 for diff in story.diff(txn, YChange::identity) {
647 let item_length = out_len(&diff.insert);
648 if matches!(diff.insert, Out::YMap(_)) && start <= offset && offset < end {
649 return Err(EditError::ParagraphBoundary { start, end });
650 }
651 offset += item_length;
652 }
653 Ok(())
654}
655
656fn check_text_bounds<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
657 let length = story.len(txn);
658 if start > end || end > length {
659 return Err(EditError::OutOfBounds {
660 index: end.max(start),
661 length,
662 });
663 }
664 Ok(())
665}
666
667pub(crate) fn selects_paragraph(start: u32, end: u32, paragraph_start: u32, pilcrow: u32) -> bool {
671 start <= pilcrow && (end > paragraph_start || (start == end && start >= paragraph_start))
672}
673
674fn selected_pilcrows<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> Vec<MapRef> {
676 let start = start.min(story.len(txn).saturating_sub(1));
677 let mut pilcrows = Vec::new();
678 let mut paragraph_start = 0;
679 let mut offset = 0;
680 for diff in story.diff(txn, YChange::identity) {
681 let item_length = out_len(&diff.insert);
682 if let Out::YMap(map) = diff.insert {
683 if selects_paragraph(start, end, paragraph_start, offset) {
684 pilcrows.push(map);
685 }
686 paragraph_start = offset + item_length;
687 }
688 offset += item_length;
689 }
690 pilcrows
691}
692
693fn paragraph_text_segments<T: ReadTxn>(
694 story: &TextRef,
695 txn: &T,
696 start: u32,
697 end: u32,
698) -> Vec<(u32, u32)> {
699 let mut segments = Vec::new();
700 let mut paragraph_start = 0;
701 let mut offset = 0;
702 for diff in story.diff(txn, YChange::identity) {
703 let item_length = out_len(&diff.insert);
704 if matches!(diff.insert, Out::YMap(_)) {
705 let segment_start = start.max(paragraph_start);
706 let segment_end = end.min(offset);
707 if segment_start < segment_end {
708 segments.push((segment_start, segment_end));
709 }
710 paragraph_start = offset + item_length;
711 }
712 offset += item_length;
713 }
714 segments
715}
716
717fn text_in_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> String {
718 let mut output = String::new();
719 let mut offset = 0;
720 for diff in story.diff(txn, YChange::identity) {
721 let length = out_len(&diff.insert);
722 if let Out::Any(Any::String(text)) = diff.insert {
723 let overlap_start = start.saturating_sub(offset).min(length);
724 let overlap_end = end.saturating_sub(offset).min(length);
725 if overlap_end > overlap_start {
726 let utf16 = text.encode_utf16().collect::<Vec<_>>();
727 output.push_str(&String::from_utf16_lossy(
728 &utf16[overlap_start as usize..overlap_end as usize],
729 ));
730 }
731 }
732 offset += length;
733 if offset >= end {
734 break;
735 }
736 }
737 output
738}
739
740fn insert_styled_text(
741 story: &TextRef,
742 txn: &mut TransactionMut<'_>,
743 index: u32,
744 text: &str,
745 style: &TextStyle,
746) {
747 story.insert(txn, index, text);
748 let length = text.encode_utf16().count() as u32;
749 for (key, value) in style_values(style) {
750 story.format(txn, index, length, Attrs::from([(Arc::from(key), value)]));
751 }
752}
753
754fn style_values(style: &TextStyle) -> [(&'static str, Any); 9] {
755 [
756 ("bold", style.bold.map(Any::Bool).unwrap_or(Any::Null)),
757 ("italic", style.italic.map(Any::Bool).unwrap_or(Any::Null)),
758 (
759 "fontSize",
760 style.font_size_pt.map(Any::Number).unwrap_or(Any::Null),
761 ),
762 (
763 "color",
764 style.color.as_deref().map(Any::from).unwrap_or(Any::Null),
765 ),
766 (
767 "fontFamily",
768 style
769 .font_family
770 .as_deref()
771 .map(Any::from)
772 .unwrap_or(Any::Null),
773 ),
774 (
775 "underline",
776 style
777 .underline
778 .as_deref()
779 .map(Any::from)
780 .unwrap_or(Any::Null),
781 ),
782 (
783 "spacing",
784 style.spacing_pt.map(Any::Number).unwrap_or(Any::Null),
785 ),
786 (
787 "baseline",
788 style.baseline_pct.map(Any::Number).unwrap_or(Any::Null),
789 ),
790 (
791 "caps",
792 style
793 .caps
794 .map(|caps| Any::from(caps.as_attribute()))
795 .unwrap_or(Any::Null),
796 ),
797 ]
798}
799
800fn attrs_from_patch(patch: &TextStylePatch) -> Attrs {
801 let mut attrs = Attrs::default();
802 insert_option(&mut attrs, "bold", patch.bold.map(Any::Bool));
803 insert_option(&mut attrs, "italic", patch.italic.map(Any::Bool));
804 insert_option(&mut attrs, "fontSize", patch.font_size_pt.map(Any::Number));
805 insert_option(&mut attrs, "color", patch.color.as_deref().map(Any::from));
806 insert_option(
807 &mut attrs,
808 "fontFamily",
809 patch.font_family.as_deref().map(Any::from),
810 );
811 insert_option(
812 &mut attrs,
813 "underline",
814 patch.underline.as_deref().map(Any::from),
815 );
816 insert_option(&mut attrs, "spacing", patch.spacing_pt.map(Any::Number));
817 insert_option(&mut attrs, "baseline", patch.baseline_pct.map(Any::Number));
818 attrs
819}
820
821fn insert_option(attrs: &mut Attrs, key: &str, value: Option<Any>) {
822 if let Some(value) = value {
823 attrs.insert(Arc::from(key), value);
824 }
825}
826
827fn style_from_run_properties(properties: &RunProperties, theme: Option<&Theme>) -> TextStyle {
828 TextStyle {
829 bold: properties.bold,
830 italic: properties.italic,
831 font_size_pt: properties.font_size_pt,
832 color: resolve_color_value_to_hex_with_theme(properties.color.as_ref(), theme),
833 font_family: properties.font_family.clone(),
834 underline: properties.underline.clone(),
835 spacing_pt: properties.spacing_pt,
836 baseline_pct: properties.baseline_pct,
837 caps: properties.caps,
838 }
839}
840
841pub(crate) fn style_from_attrs(attrs: Option<&Attrs>) -> TextStyle {
842 TextStyle {
843 bold: attrs.and_then(|attrs| any_bool(attrs.get("bold"))),
844 italic: attrs.and_then(|attrs| any_bool(attrs.get("italic"))),
845 font_size_pt: attrs.and_then(|attrs| any_number(attrs.get("fontSize"))),
846 color: attrs.and_then(|attrs| any_string(attrs.get("color"))),
847 font_family: attrs.and_then(|attrs| any_string(attrs.get("fontFamily"))),
848 underline: attrs.and_then(|attrs| any_string(attrs.get("underline"))),
849 spacing_pt: attrs.and_then(|attrs| any_number(attrs.get("spacing"))),
850 baseline_pct: attrs.and_then(|attrs| any_number(attrs.get("baseline"))),
851 caps: attrs
852 .and_then(|attrs| any_string(attrs.get("caps")))
853 .as_deref()
854 .and_then(TextCaps::from_attribute),
855 }
856}
857
858fn any_bool(value: Option<&Any>) -> Option<bool> {
859 match value {
860 Some(Any::Bool(value)) => Some(*value),
861 _ => None,
862 }
863}
864
865fn any_number(value: Option<&Any>) -> Option<f64> {
866 match value {
867 Some(Any::Number(value)) if value.is_finite() => Some(*value),
868 Some(Any::BigInt(value)) => Some(*value as f64),
869 _ => None,
870 }
871}
872
873fn any_string(value: Option<&Any>) -> Option<String> {
874 match value {
875 Some(Any::String(value)) => Some(value.to_string()),
876 _ => None,
877 }
878}
879
880fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
881 match map.get(txn, key) {
882 Some(Out::Any(Any::String(value))) => Some(value.to_string()),
883 _ => None,
884 }
885}
886
887fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
888 match map.get(txn, key) {
889 Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
890 Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
891 _ => None,
892 }
893}
894
895fn out_len(value: &Out) -> u32 {
896 match value {
897 Out::Any(Any::String(value)) => value.encode_utf16().count() as u32,
898 _ => 1,
899 }
900}