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, 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
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
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 &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
520/// The snapshot `snapshot_story` reads back from a story `seed_story` wrote,
521/// computed without materializing a scratch document.
522pub(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 &paragraph.runs {
543                if run.text.is_empty() {
544                    continue;
545                }
546                let style = style_from_run_properties(&run.properties, theme);
547                // yrs' format-gap cleanup deletes every marker between two
548                // identically styled runs, so their text items squash into a
549                // single run in the snapshot.
550                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
587/// Attribute reads drop non-finite numbers, so the snapshot applies the same
588/// normalization the seeded `Any::Number` attributes go through.
589fn 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
667/// Whether a paragraph-level edit over `start..end` touches the paragraph spanning
668/// `paragraph_start..=pilcrow`. A collapsed caret picks the paragraph it sits in; a range
669/// stopping at a paragraph start does not.
670pub(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
674/// The pilcrow of every paragraph the range touches, as [`selects_paragraph`] decides.
675fn 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}