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, 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
96fn 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 snapshot_story<T: ReadTxn>(
521 story: &TextRef,
522 txn: &T,
523 story_id: &str,
524) -> EditResult<StorySnapshot> {
525 validate_story(story, txn, story_id)?;
526 let mut paragraphs = Vec::new();
527 let mut runs = Vec::new();
528 for diff in story.diff(txn, YChange::identity) {
529 match diff.insert {
530 Out::Any(Any::String(text)) => runs.push(TextRunSnapshot {
531 text: text.to_string(),
532 style: style_from_attrs(diff.attributes.as_deref()),
533 }),
534 Out::YMap(map) => {
535 paragraphs.push(ParagraphSnapshot {
536 id: map_string(&map, txn, PARA_ID).unwrap_or_default(),
537 alignment: map_string(&map, txn, "alignment"),
538 level: map_number(&map, txn, "level").unwrap_or_default() as u32,
539 bullet_json: map_string(&map, txn, "bulletJson"),
540 runs: std::mem::take(&mut runs),
541 });
542 }
543 _ => {}
544 }
545 }
546 Ok(StorySnapshot {
547 id: story_id.to_owned(),
548 length: story.len(txn),
549 paragraphs,
550 })
551}
552
553fn story_ref<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<TextRef> {
554 txn.get_map(STORIES)
555 .and_then(|stories| stories.get(txn, story_id))
556 .and_then(|value| value.cast::<TextRef>().ok())
557 .ok_or_else(|| EditError::StoryNotFound(story_id.to_owned()))
558}
559
560fn final_pilcrow_index<T: ReadTxn>(story: &TextRef, txn: &T) -> EditResult<u32> {
561 validate_story(story, txn, "requested")?;
562 Ok(story.len(txn) - 1)
563}
564
565fn check_text_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
566 check_text_bounds(story, txn, start, end)?;
567 let mut offset = 0;
568 for diff in story.diff(txn, YChange::identity) {
569 let item_length = out_len(&diff.insert);
570 if matches!(diff.insert, Out::YMap(_)) && start <= offset && offset < end {
571 return Err(EditError::ParagraphBoundary { start, end });
572 }
573 offset += item_length;
574 }
575 Ok(())
576}
577
578fn check_text_bounds<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
579 let length = story.len(txn);
580 if start > end || end > length {
581 return Err(EditError::OutOfBounds {
582 index: end.max(start),
583 length,
584 });
585 }
586 Ok(())
587}
588
589fn selected_pilcrows<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> Vec<MapRef> {
592 let start = start.min(story.len(txn).saturating_sub(1));
593 let mut pilcrows = Vec::new();
594 let mut paragraph_start = 0;
595 let mut offset = 0;
596 for diff in story.diff(txn, YChange::identity) {
597 let item_length = out_len(&diff.insert);
598 if let Out::YMap(map) = diff.insert {
599 let touches = start <= offset
600 && (end > paragraph_start || (start == end && start >= paragraph_start));
601 if touches {
602 pilcrows.push(map);
603 }
604 paragraph_start = offset + item_length;
605 }
606 offset += item_length;
607 }
608 pilcrows
609}
610
611fn paragraph_text_segments<T: ReadTxn>(
612 story: &TextRef,
613 txn: &T,
614 start: u32,
615 end: u32,
616) -> Vec<(u32, u32)> {
617 let mut segments = Vec::new();
618 let mut paragraph_start = 0;
619 let mut offset = 0;
620 for diff in story.diff(txn, YChange::identity) {
621 let item_length = out_len(&diff.insert);
622 if matches!(diff.insert, Out::YMap(_)) {
623 let segment_start = start.max(paragraph_start);
624 let segment_end = end.min(offset);
625 if segment_start < segment_end {
626 segments.push((segment_start, segment_end));
627 }
628 paragraph_start = offset + item_length;
629 }
630 offset += item_length;
631 }
632 segments
633}
634
635fn text_in_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> String {
636 let mut output = String::new();
637 let mut offset = 0;
638 for diff in story.diff(txn, YChange::identity) {
639 let length = out_len(&diff.insert);
640 if let Out::Any(Any::String(text)) = diff.insert {
641 let overlap_start = start.saturating_sub(offset).min(length);
642 let overlap_end = end.saturating_sub(offset).min(length);
643 if overlap_end > overlap_start {
644 let utf16 = text.encode_utf16().collect::<Vec<_>>();
645 output.push_str(&String::from_utf16_lossy(
646 &utf16[overlap_start as usize..overlap_end as usize],
647 ));
648 }
649 }
650 offset += length;
651 if offset >= end {
652 break;
653 }
654 }
655 output
656}
657
658fn insert_styled_text(
659 story: &TextRef,
660 txn: &mut TransactionMut<'_>,
661 index: u32,
662 text: &str,
663 style: &TextStyle,
664) {
665 story.insert(txn, index, text);
666 let length = text.encode_utf16().count() as u32;
667 for (key, value) in style_values(style) {
668 story.format(txn, index, length, Attrs::from([(Arc::from(key), value)]));
669 }
670}
671
672fn style_values(style: &TextStyle) -> [(&'static str, Any); 8] {
673 [
674 ("bold", style.bold.map(Any::Bool).unwrap_or(Any::Null)),
675 ("italic", style.italic.map(Any::Bool).unwrap_or(Any::Null)),
676 (
677 "fontSize",
678 style.font_size_pt.map(Any::Number).unwrap_or(Any::Null),
679 ),
680 (
681 "color",
682 style.color.as_deref().map(Any::from).unwrap_or(Any::Null),
683 ),
684 (
685 "fontFamily",
686 style
687 .font_family
688 .as_deref()
689 .map(Any::from)
690 .unwrap_or(Any::Null),
691 ),
692 (
693 "underline",
694 style
695 .underline
696 .as_deref()
697 .map(Any::from)
698 .unwrap_or(Any::Null),
699 ),
700 (
701 "spacing",
702 style.spacing_pt.map(Any::Number).unwrap_or(Any::Null),
703 ),
704 (
705 "baseline",
706 style.baseline_pct.map(Any::Number).unwrap_or(Any::Null),
707 ),
708 ]
709}
710
711fn attrs_from_patch(patch: &TextStylePatch) -> Attrs {
712 let mut attrs = Attrs::default();
713 insert_option(&mut attrs, "bold", patch.bold.map(Any::Bool));
714 insert_option(&mut attrs, "italic", patch.italic.map(Any::Bool));
715 insert_option(&mut attrs, "fontSize", patch.font_size_pt.map(Any::Number));
716 insert_option(&mut attrs, "color", patch.color.as_deref().map(Any::from));
717 insert_option(
718 &mut attrs,
719 "fontFamily",
720 patch.font_family.as_deref().map(Any::from),
721 );
722 insert_option(
723 &mut attrs,
724 "underline",
725 patch.underline.as_deref().map(Any::from),
726 );
727 insert_option(&mut attrs, "spacing", patch.spacing_pt.map(Any::Number));
728 insert_option(&mut attrs, "baseline", patch.baseline_pct.map(Any::Number));
729 attrs
730}
731
732fn insert_option(attrs: &mut Attrs, key: &str, value: Option<Any>) {
733 if let Some(value) = value {
734 attrs.insert(Arc::from(key), value);
735 }
736}
737
738fn style_from_run_properties(properties: &RunProperties, theme: Option<&Theme>) -> TextStyle {
739 TextStyle {
740 bold: properties.bold,
741 italic: properties.italic,
742 font_size_pt: properties.font_size_pt,
743 color: resolve_color_value_to_hex_with_theme(properties.color.as_ref(), theme),
744 font_family: properties.font_family.clone(),
745 underline: properties.underline.clone(),
746 spacing_pt: properties.spacing_pt,
747 baseline_pct: properties.baseline_pct,
748 }
749}
750
751fn style_from_attrs(attrs: Option<&Attrs>) -> TextStyle {
752 TextStyle {
753 bold: attrs.and_then(|attrs| any_bool(attrs.get("bold"))),
754 italic: attrs.and_then(|attrs| any_bool(attrs.get("italic"))),
755 font_size_pt: attrs.and_then(|attrs| any_number(attrs.get("fontSize"))),
756 color: attrs.and_then(|attrs| any_string(attrs.get("color"))),
757 font_family: attrs.and_then(|attrs| any_string(attrs.get("fontFamily"))),
758 underline: attrs.and_then(|attrs| any_string(attrs.get("underline"))),
759 spacing_pt: attrs.and_then(|attrs| any_number(attrs.get("spacing"))),
760 baseline_pct: attrs.and_then(|attrs| any_number(attrs.get("baseline"))),
761 }
762}
763
764fn any_bool(value: Option<&Any>) -> Option<bool> {
765 match value {
766 Some(Any::Bool(value)) => Some(*value),
767 _ => None,
768 }
769}
770
771fn any_number(value: Option<&Any>) -> Option<f64> {
772 match value {
773 Some(Any::Number(value)) if value.is_finite() => Some(*value),
774 Some(Any::BigInt(value)) => Some(*value as f64),
775 _ => None,
776 }
777}
778
779fn any_string(value: Option<&Any>) -> Option<String> {
780 match value {
781 Some(Any::String(value)) => Some(value.to_string()),
782 _ => None,
783 }
784}
785
786fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
787 match map.get(txn, key) {
788 Some(Out::Any(Any::String(value))) => Some(value.to_string()),
789 _ => None,
790 }
791}
792
793fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
794 match map.get(txn, key) {
795 Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
796 Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
797 _ => None,
798 }
799}
800
801fn out_len(value: &Out) -> u32 {
802 match value {
803 Out::Any(Any::String(value)) => value.encode_utf16().count() as u32,
804 _ => 1,
805 }
806}