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::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
43/// The values land in schema-typed attributes, so junk must fail the edit
44/// rather than the file.
45pub(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 &paragraph.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    /// Sets `a:pPr@algn` on every paragraph the range touches; `None` clears
384    /// the value so the placeholder cascade applies again.
385    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
589/// The pilcrow of every paragraph the range touches. A collapsed caret picks
590/// the paragraph it sits in; a range stopping at a paragraph start does not.
591fn 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}