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::updates::{decoder::Decode, encoder::Encode};
9use yrs::{
10    Any, Assoc, IndexedSequence, Map, MapPrelim, MapRef, Out, ReadTxn, StickyIndex, Text,
11    TextPrelim, TextRef, Transact, TransactionMut,
12};
13
14use crate::model::validate_xml_text;
15use crate::{
16    CaretAnchor, DeckSession, EditError, EditResult, KIND, PARA_ID, PILCROW_KIND,
17    ParagraphSnapshot, ParagraphSource, STORIES, StorySnapshot, TextCaps, TextReceipt,
18    TextRunSnapshot, TextStyle, TextStylePatch,
19};
20
21const UNDERLINE_TYPES: [&str; 18] = [
22    "none",
23    "words",
24    "sng",
25    "dbl",
26    "heavy",
27    "dotted",
28    "dottedHeavy",
29    "dash",
30    "dashHeavy",
31    "dashLong",
32    "dashLongHeavy",
33    "dotDash",
34    "dotDashHeavy",
35    "dotDotDash",
36    "dotDotDashHeavy",
37    "wavy",
38    "wavyHeavy",
39    "wavyDbl",
40];
41
42const ALIGNMENTS: [&str; 7] = ["l", "ctr", "r", "just", "justLow", "dist", "thaiDist"];
43
44/// The values land in schema-typed attributes, so junk must fail the edit
45/// rather than the file.
46pub(crate) fn validate_style_values(
47    font_family: Option<&str>,
48    underline: Option<&str>,
49    color: Option<&str>,
50    font_size_pt: Option<f64>,
51    spacing_pt: Option<f64>,
52    baseline_pct: Option<f64>,
53    kern_pt: Option<f64>,
54) -> EditResult<()> {
55    if let Some(font_family) = font_family {
56        validate_xml_text(font_family)?;
57    }
58    if let Some(underline) = underline
59        && !UNDERLINE_TYPES.contains(&underline)
60    {
61        return Err(EditError::InvalidText(format!(
62            "unrecognized underline type {underline:?}"
63        )));
64    }
65    if let Some(color) = color {
66        let rgb = color.strip_prefix('#').unwrap_or(color);
67        if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
68            return Err(EditError::InvalidText(format!(
69                "color {color:?} must be a six-digit hex value"
70            )));
71        }
72    }
73    if let Some(size) = font_size_pt
74        && (!size.is_finite() || !(1.0..=4_000.0).contains(&size))
75    {
76        return Err(EditError::InvalidText(format!(
77            "font size {size}pt is outside the 1-4000pt range"
78        )));
79    }
80    if let Some(spacing) = spacing_pt
81        && (!spacing.is_finite() || !(-4_000.0..=4_000.0).contains(&spacing))
82    {
83        return Err(EditError::InvalidText(format!(
84            "letter spacing {spacing}pt is outside the -4000-4000pt range"
85        )));
86    }
87    if let Some(kern) = kern_pt
88        && (!kern.is_finite() || !(0.0..=4_000.0).contains(&kern))
89    {
90        return Err(EditError::InvalidText(format!(
91            "kerning threshold {kern}pt is outside the 0-4000pt range"
92        )));
93    }
94    if let Some(baseline) = baseline_pct
95        && (!baseline.is_finite()
96            || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&(baseline * 1000.0)))
97    {
98        return Err(EditError::InvalidText(
99            "baseline exceeds the signed percentage range".into(),
100        ));
101    }
102    Ok(())
103}
104
105pub(crate) fn validate_alignment(alignment: Option<&str>) -> EditResult<()> {
106    if let Some(alignment) = alignment
107        && !ALIGNMENTS.contains(&alignment)
108    {
109        return Err(EditError::InvalidText(format!(
110            "unrecognized paragraph alignment {alignment:?}"
111        )));
112    }
113    Ok(())
114}
115
116pub(crate) fn seed_story(
117    stories: &MapRef,
118    txn: &mut TransactionMut<'_>,
119    story_id: &str,
120    body: &TextBody,
121    theme: Option<&Theme>,
122) -> EditResult<()> {
123    let story = stories.insert(txn, story_id, TextPrelim::new(""));
124    if body.paragraphs.is_empty() {
125        append_pilcrow(&story, txn, &format!("para:{story_id}:0"), None, 0, None);
126        return Ok(());
127    }
128    for (paragraph_index, paragraph) in body.paragraphs.iter().enumerate() {
129        for run in &paragraph.runs {
130            if !run.text.is_empty() {
131                let style = style_from_run_properties(&run.properties, theme);
132                let index = story.len(txn);
133                insert_styled_text(&story, txn, index, &run.text, &style);
134            }
135        }
136        let bullet_json = paragraph
137            .properties
138            .bullet
139            .as_ref()
140            .map(serde_json::to_string)
141            .transpose()
142            .map_err(|error| EditError::Json(error.to_string()))?;
143        append_pilcrow(
144            &story,
145            txn,
146            &format!("para:{story_id}:{paragraph_index}"),
147            paragraph.properties.alignment.as_deref(),
148            paragraph.properties.level,
149            bullet_json.as_deref(),
150        );
151    }
152    Ok(())
153}
154
155pub(crate) fn seed_plain_story(
156    stories: &MapRef,
157    txn: &mut TransactionMut<'_>,
158    story_id: &str,
159    paragraph_id: &str,
160    text: &str,
161    style: &TextStyle,
162) -> TextRef {
163    let story = stories.insert(txn, story_id, TextPrelim::new(""));
164    if !text.is_empty() {
165        insert_styled_text(&story, txn, 0, text, style);
166    }
167    append_pilcrow(&story, txn, paragraph_id, None, 0, None);
168    story
169}
170
171pub(crate) fn import_source_numbering_restarts(
172    doc: &yrs::Doc,
173    package: &pptx_parse::PptxPackage,
174) -> EditResult<()> {
175    let source = crate::doc_with_client_id(crate::BOOTSTRAP_CLIENT_ID);
176    crate::deck::seed_doc(&source, package, "")?;
177    let source_txn = source.transact();
178    let source_stories = crate::deck::required_map(&source_txn, STORIES)?;
179    let mut restarts = std::collections::HashMap::new();
180    for (_, value) in source_stories.iter(&source_txn) {
181        let Out::YText(story) = value else { continue };
182        for diff in story.diff(&source_txn, YChange::identity) {
183            let Out::YMap(map) = diff.insert else {
184                continue;
185            };
186            let (Some(id), Some(json)) = (
187                map_string(&map, &source_txn, PARA_ID),
188                map_string(&map, &source_txn, "bulletJson"),
189            ) else {
190                continue;
191            };
192            let mut bullet: pptx_parse::Bullet =
193                serde_json::from_str(&json).map_err(|error| EditError::Json(error.to_string()))?;
194            if let pptx_parse::Bullet::AutoNumber {
195                restart: restart @ true,
196                ..
197            } = &mut bullet
198            {
199                *restart = false;
200                restarts.insert(id, (bullet, json));
201            }
202        }
203    }
204    if restarts.is_empty() {
205        return Ok(());
206    }
207    let mut txn = doc.transact_mut_with(crate::MIGRATE_ORIGIN);
208    let stories = crate::deck::required_map(&txn, STORIES)?;
209    let mut updates = Vec::new();
210    for (story_id, value) in stories.iter(&txn) {
211        let Out::YText(story) = value else { continue };
212        for diff in story.diff(&txn, YChange::identity) {
213            let Out::YMap(map) = diff.insert else {
214                continue;
215            };
216            let Some(id) = map_string(&map, &txn, PARA_ID) else {
217                continue;
218            };
219            let Some((legacy, source)) = restarts.get(&id) else {
220                continue;
221            };
222            let current = map_string(&map, &txn, "bulletJson")
223                .and_then(|json| serde_json::from_str::<pptx_parse::Bullet>(&json).ok());
224            if current.as_ref() == Some(legacy) {
225                updates.push(((story_id.to_owned(), id), map, source.clone()));
226            }
227        }
228    }
229    updates.sort_by(|left, right| left.0.cmp(&right.0));
230    for (_, map, json) in updates {
231        map.insert(&mut txn, "bulletJson", json);
232    }
233    Ok(())
234}
235
236fn append_pilcrow(
237    story: &TextRef,
238    txn: &mut TransactionMut<'_>,
239    paragraph_id: &str,
240    alignment: Option<&str>,
241    level: u32,
242    bullet_json: Option<&str>,
243) {
244    let index = story.len(txn);
245    let pilcrow =
246        story.insert_embed_with_attributes(txn, index, MapPrelim::default(), Attrs::default());
247    pilcrow.insert(txn, KIND, PILCROW_KIND);
248    pilcrow.insert(txn, PARA_ID, paragraph_id);
249    pilcrow.insert(txn, "level", level as f64);
250    if let Some(alignment) = alignment {
251        pilcrow.insert(txn, "alignment", alignment);
252    }
253    if let Some(bullet_json) = bullet_json {
254        pilcrow.insert(txn, "bulletJson", bullet_json);
255    }
256}
257
258fn replacement_bullet(enabled: bool) -> EditResult<String> {
259    let bullet = if enabled {
260        pptx_parse::Bullet::Character {
261            value: "•".into()
262        }
263    } else {
264        pptx_parse::Bullet::None
265    };
266    serde_json::to_string(&bullet).map_err(|error| EditError::Json(error.to_string()))
267}
268
269fn styled_characters<'a>(
270    runs: impl Iterator<Item = (&'a str, &'a TextStyle)>,
271) -> Vec<(char, &'a TextStyle)> {
272    runs.flat_map(|(text, style)| text.chars().map(move |character| (character, style)))
273        .collect()
274}
275
276fn paragraph_offsets(story: &StorySnapshot) -> Vec<u32> {
277    story
278        .paragraphs
279        .iter()
280        .scan(0, |offset, paragraph| {
281            let start = *offset;
282            *offset += paragraph
283                .runs
284                .iter()
285                .map(|run| run.text.encode_utf16().count() as u32)
286                .sum::<u32>()
287                + 1;
288            Some(start)
289        })
290        .collect()
291}
292
293impl DeckSession {
294    pub fn story(&self, story_id: &str) -> EditResult<StorySnapshot> {
295        let txn = self.doc.transact();
296        let story = story_ref(&txn, story_id)?;
297        snapshot_story(&story, &txn, story_id)
298    }
299
300    pub fn anchor_caret(&self, story_id: &str, index: u32) -> EditResult<CaretAnchor> {
301        let txn = self.doc.transact();
302        let story = story_ref(&txn, story_id)?;
303        let length = final_pilcrow_index(&story, &txn)?;
304        if index > length {
305            return Err(EditError::OutOfBounds { index, length });
306        }
307        let position = if index == 0 {
308            StickyIndex::from_type(&txn, &story, Assoc::Before)
309        } else {
310            story
311                .sticky_index(&txn, index, Assoc::After)
312                .ok_or(EditError::OutOfBounds { index, length })?
313        };
314        Ok(CaretAnchor {
315            story_id: story_id.to_owned(),
316            position,
317        })
318    }
319
320    pub fn resolve_caret_anchor(&self, anchor: &CaretAnchor) -> Option<u32> {
321        let txn = self.doc.transact();
322        let story = story_ref(&txn, &anchor.story_id).ok()?;
323        let offset = anchor.position.get_offset(&txn)?;
324        let expected = BranchPtr::from(<TextRef as AsRef<Branch>>::as_ref(&story));
325        if offset.branch != expected {
326            return None;
327        }
328        let length = final_pilcrow_index(&story, &txn).ok()?;
329        Some(offset.index.min(length))
330    }
331
332    pub fn set_story_paragraphs(
333        &self,
334        context: &crate::EditCtx,
335        story_id: &str,
336        paragraphs: &[crate::TextParagraphDraft],
337    ) -> EditResult<StorySnapshot> {
338        if paragraphs.is_empty() || paragraphs.len() > 100 {
339            return Err(EditError::InvalidText("supply 1 to 100 paragraphs".into()));
340        }
341        let mut length = 0;
342        for paragraph in paragraphs {
343            if paragraph.runs.len() > 64 {
344                return Err(EditError::InvalidText(
345                    "supply up to 64 runs per paragraph".into(),
346                ));
347            }
348            if paragraph
349                .alignment
350                .as_deref()
351                .is_some_and(|alignment| !ALIGNMENTS.contains(&alignment))
352            {
353                return Err(EditError::InvalidText(
354                    "unrecognized paragraph alignment".into(),
355                ));
356            }
357            for run in &paragraph.runs {
358                validate_xml_text(&run.text)?;
359                if run.text.contains('\r') {
360                    return Err(EditError::InvalidText(
361                        "run text must use line feeds for soft line breaks".into(),
362                    ));
363                }
364                let style = &run.style;
365                validate_style_values(
366                    style.font_family.as_deref(),
367                    style.underline.as_deref(),
368                    style.color.as_deref(),
369                    style.font_size_pt,
370                    style.spacing_pt,
371                    style.baseline_pct,
372                    style.kern_pt,
373                )?;
374                length += run.text.encode_utf16().count();
375            }
376        }
377        if length > 16_000 {
378            return Err(EditError::InvalidText(
379                "text exceeds the 16000-character limit".into(),
380            ));
381        }
382        let previous = self.story(story_id)?;
383        let old_count = previous.paragraphs.len();
384        let new_count = paragraphs.len();
385        let old_text: Vec<_> = previous
386            .paragraphs
387            .iter()
388            .map(|paragraph| {
389                styled_characters(
390                    paragraph
391                        .runs
392                        .iter()
393                        .map(|run| (run.text.as_str(), &run.style)),
394                )
395            })
396            .collect();
397        let draft_text: Vec<_> = paragraphs
398            .iter()
399            .map(|paragraph| {
400                styled_characters(
401                    paragraph
402                        .runs
403                        .iter()
404                        .map(|run| (run.text.as_str(), &run.style)),
405                )
406            })
407            .collect();
408        let bullets = paragraphs
409            .iter()
410            .map(|paragraph| paragraph.bullet.map(replacement_bullet).transpose())
411            .collect::<EditResult<Vec<_>>>()?;
412        let equal = |old: usize, draft: usize| {
413            old_text[old] == draft_text[draft]
414                && paragraphs[draft]
415                    .alignment
416                    .as_ref()
417                    .is_none_or(|alignment| {
418                        previous.paragraphs[old].alignment.as_ref() == Some(alignment)
419                    })
420                && bullets[draft].as_ref().is_none_or(|bullet| {
421                    previous.paragraphs[old].bullet_json.as_ref() == Some(bullet)
422                })
423        };
424        if old_count == new_count && (0..old_count).all(|index| equal(index, index)) {
425            return self.story(story_id);
426        }
427        let shared = old_count.min(new_count);
428        let prefix = (0..shared - 1)
429            .take_while(|&index| equal(index, index))
430            .count();
431        let suffix = (0..shared - prefix)
432            .take_while(|&index| equal(old_count - 1 - index, new_count - 1 - index))
433            .count();
434        let old_middle = old_count - prefix - suffix;
435        let new_middle = new_count - prefix - suffix;
436        let old_starts = paragraph_offsets(&previous);
437        if old_middle > new_middle {
438            let fields =
439                crate::target::field_paragraphs(&self.package, &self.snapshot()?, story_id);
440            let txn = self.doc.transact();
441            let story = story_ref(&txn, story_id)?;
442            let properties = paragraph_projections(&story, &txn, story_id);
443            let first = prefix + new_middle - usize::from(suffix == 0);
444            let end = old_count - suffix + usize::from(suffix > 0);
445            if properties[first..end].iter().any(|(_, map, _)| {
446                map_string(map, &txn, PARA_ID).is_some_and(|id| fields.contains(&id))
447                    || matches!(map.get(&txn, "hasFields"), Some(Out::Any(Any::Bool(true))))
448            }) {
449                let start = old_starts[prefix + new_middle] - u32::from(suffix == 0);
450                return Err(EditError::ParagraphBoundary {
451                    start,
452                    end: start + 1,
453                });
454            }
455        }
456        let mut templates = vec![None; new_count];
457        if new_middle > old_middle {
458            let (range, template) = if suffix > 0 {
459                let template = if prefix + old_middle > 0 {
460                    &previous.paragraphs[prefix + old_middle - 1]
461                } else {
462                    &previous.paragraphs[old_count - suffix]
463                };
464                (prefix + old_middle..prefix + new_middle, template)
465            } else {
466                (
467                    old_count - 1..new_count - 1,
468                    &previous.paragraphs[old_count - 1],
469                )
470            };
471            for slot in &mut templates[range] {
472                *slot = Some(template.clone());
473            }
474        } else if old_middle > new_middle && suffix == 0 {
475            templates[new_count - 1] = Some(previous.paragraphs[new_count - 1].clone());
476        }
477        if templates.iter().any(Option::is_some) {
478            let source_xml = self.source_paragraph_xml(story_id)?;
479            let source_prefix = format!("para:{story_id}:");
480            for template in templates.iter_mut().flatten() {
481                if template.preserved_xml.is_none() {
482                    template.preserved_xml = template
483                        .id
484                        .strip_prefix(&source_prefix)
485                        .and_then(|index| index.parse::<usize>().ok())
486                        .and_then(|index| source_xml.get(index).cloned());
487                }
488            }
489        }
490        self.with_undo_capture(|| {
491            if new_middle > old_middle {
492                let index = if suffix > 0 {
493                    old_starts[old_count - suffix]
494                } else {
495                    previous.length - 1
496                };
497                for _ in 0..new_middle - old_middle {
498                    self.insert_paragraph_break(context, story_id, index)?;
499                }
500            } else if old_middle > new_middle {
501                for index in (prefix + new_middle..old_count - suffix).rev() {
502                    let start = old_starts[index];
503                    let end = old_starts
504                        .get(index + 1)
505                        .copied()
506                        .unwrap_or(previous.length)
507                        - 1;
508                    if start != end {
509                        self.delete_text(context, story_id, start, end)?;
510                    }
511                    let marker = if suffix > 0 { start } else { start - 1 };
512                    self.remove_replacement_paragraph(context, story_id, marker, suffix == 0)?;
513                }
514            }
515            let current = self.story(story_id)?;
516            if current.paragraphs.len() != new_count {
517                return Err(EditError::InvalidState(
518                    "story replacement produced an unexpected paragraph count".into(),
519                ));
520            }
521            let starts = paragraph_offsets(&current);
522            for (index, (paragraph, draft)) in
523                current.paragraphs.iter().zip(&draft_text).enumerate().rev()
524            {
525                let old = styled_characters(
526                    paragraph
527                        .runs
528                        .iter()
529                        .map(|run| (run.text.as_str(), &run.style)),
530                );
531                if old == *draft {
532                    continue;
533                }
534                let text_prefix = old.iter().zip(draft).take_while(|(a, b)| a == b).count();
535                let text_suffix = old
536                    .iter()
537                    .rev()
538                    .zip(draft.iter().rev())
539                    .take(old.len().min(draft.len()) - text_prefix)
540                    .take_while(|(a, b)| a == b)
541                    .count();
542                let mut offset = starts[index]
543                    + old[..text_prefix]
544                        .iter()
545                        .map(|(character, _)| character.len_utf16() as u32)
546                        .sum::<u32>();
547                let removed = old[text_prefix..old.len() - text_suffix]
548                    .iter()
549                    .map(|(character, _)| character.len_utf16() as u32)
550                    .sum::<u32>();
551                if removed > 0 {
552                    self.delete_text(context, story_id, offset, offset + removed)?;
553                }
554                let middle = &draft[text_prefix..draft.len() - text_suffix];
555                let mut cursor = 0;
556                while cursor < middle.len() {
557                    let style = middle[cursor].1;
558                    let count = middle[cursor..]
559                        .iter()
560                        .take_while(|(_, candidate)| *candidate == style)
561                        .count();
562                    let text: String = middle[cursor..cursor + count]
563                        .iter()
564                        .map(|(character, _)| *character)
565                        .collect();
566                    self.insert_text(context, story_id, offset, &text, style)?;
567                    offset += text.encode_utf16().count() as u32;
568                    cursor += count;
569                }
570            }
571            let current = self.story(story_id)?;
572            let starts = paragraph_offsets(&current);
573            for (index, template) in templates.iter().enumerate() {
574                let Some(template) = template else { continue };
575                self.set_replacement_properties(context, story_id, starts[index], template)?;
576                if current.paragraphs[index].alignment != template.alignment {
577                    self.set_paragraph_alignment(
578                        context,
579                        story_id,
580                        starts[index],
581                        starts[index],
582                        template.alignment.as_deref(),
583                    )?;
584                }
585            }
586            let current = self.story(story_id)?;
587            for (index, paragraph) in paragraphs.iter().enumerate() {
588                if let Some(alignment) = paragraph.alignment.as_deref()
589                    && current.paragraphs[index].alignment.as_deref() != Some(alignment)
590                {
591                    self.set_paragraph_alignment(
592                        context,
593                        story_id,
594                        starts[index],
595                        starts[index],
596                        Some(alignment),
597                    )?;
598                }
599                if let Some(bullet) = &bullets[index]
600                    && current.paragraphs[index].bullet_json.as_ref() != Some(bullet)
601                {
602                    let mut properties = current.paragraphs[index].clone();
603                    properties.bullet_json = Some(bullet.clone());
604                    self.set_replacement_properties(context, story_id, starts[index], &properties)?;
605                }
606            }
607            self.story(story_id)
608        })
609    }
610
611    fn set_replacement_properties(
612        &self,
613        context: &crate::EditCtx,
614        story_id: &str,
615        paragraph_start: u32,
616        properties: &ParagraphSnapshot,
617    ) -> EditResult<()> {
618        let mut txn = self.transact_for(context);
619        let story = story_ref(&txn, story_id)?;
620        let marker = selected_pilcrows(&story, &txn, story_id, paragraph_start, paragraph_start)
621            .pop()
622            .ok_or_else(|| EditError::InvalidState("missing replacement paragraph".into()))?;
623        let level = f64::from(properties.level);
624        if map_number(&marker, &txn, "level") != Some(level) {
625            marker.insert(&mut txn, "level", level);
626        }
627        for (key, value) in [
628            ("bulletJson", properties.bullet_json.as_deref()),
629            ("preservedXml", properties.preserved_xml.as_deref()),
630        ] {
631            if map_string(&marker, &txn, key).as_deref() != value {
632                if let Some(value) = value {
633                    marker.insert(&mut txn, key, value);
634                } else {
635                    marker.remove(&mut txn, key);
636                }
637            }
638        }
639        if properties.source_paragraphs.is_empty() {
640            marker.remove(&mut txn, "sourceParagraphs");
641        }
642        Ok(())
643    }
644
645    fn source_paragraph_xml(&self, story_id: &str) -> EditResult<Vec<String>> {
646        fn find_shape<'a>(
647            shapes: &'a [crate::ShapeSnapshot],
648            story_id: &str,
649        ) -> Option<&'a crate::ShapeSnapshot> {
650            shapes.iter().find_map(|shape| {
651                if shape.text_stories.iter().any(|story| story.id == story_id) {
652                    Some(shape)
653                } else {
654                    find_shape(&shape.children, story_id)
655                }
656            })
657        }
658        for slide in self.snapshot()?.slides {
659            let Some(shape) = find_shape(&slide.shapes, story_id) else {
660                continue;
661            };
662            let Some(part) = slide.source_part_path else {
663                return Ok(Vec::new());
664            };
665            let Some((_, path)) = shape.id.rsplit_once(":shape:") else {
666                return Ok(Vec::new());
667            };
668            let path = path
669                .split('.')
670                .map(str::parse)
671                .collect::<Result<Vec<usize>, _>>()
672                .map_err(|error| EditError::Write(error.to_string()))?;
673            let target = match crate::table::source_cell(shape, story_id) {
674                Some([row, cell]) => pptx_parse::TextTarget::TableCell { row, cell },
675                None => crate::save::text_target(story_id, &shape.id)?,
676            };
677            return pptx_parse::text_paragraph_xml(&self.package, &part, &path, &target)
678                .map_err(|error| EditError::Write(error.to_string()));
679        }
680        Ok(Vec::new())
681    }
682
683    pub fn insert_text(
684        &self,
685        context: &crate::EditCtx,
686        story_id: &str,
687        index: u32,
688        text: &str,
689        style: &TextStyle,
690    ) -> EditResult<TextReceipt> {
691        validate_xml_text(text)?;
692        validate_style_values(
693            style.font_family.as_deref(),
694            style.underline.as_deref(),
695            style.color.as_deref(),
696            style.font_size_pt,
697            style.spacing_pt,
698            style.baseline_pct,
699            style.kern_pt,
700        )?;
701        let mut txn = self.transact_for(context);
702        let story = story_ref(&txn, story_id)?;
703        let final_pilcrow = final_pilcrow_index(&story, &txn)?;
704        if index > final_pilcrow {
705            return Err(EditError::OutOfBounds {
706                index,
707                length: final_pilcrow,
708            });
709        }
710        if !text.is_empty() {
711            insert_styled_text(&story, &mut txn, index, text, style);
712        }
713        let length = text.encode_utf16().count() as u32;
714        Ok(TextReceipt {
715            story_id: story_id.to_owned(),
716            start: index,
717            end: index + length,
718            text: text.to_owned(),
719        })
720    }
721
722    pub fn delete_text(
723        &self,
724        context: &crate::EditCtx,
725        story_id: &str,
726        start: u32,
727        end: u32,
728    ) -> EditResult<TextReceipt> {
729        let mut txn = self.transact_for(context);
730        let story = story_ref(&txn, story_id)?;
731        check_text_range(&story, &txn, start, end)?;
732        let text = text_in_range(&story, &txn, start, end);
733        if end > start {
734            story.remove_range(&mut txn, start, end - start);
735        }
736        Ok(TextReceipt {
737            story_id: story_id.to_owned(),
738            start,
739            end,
740            text,
741        })
742    }
743
744    pub fn delete_text_joining_paragraphs(
745        &self,
746        context: &crate::EditCtx,
747        story_id: &str,
748        start: u32,
749        end: u32,
750    ) -> EditResult<TextReceipt> {
751        if start == end {
752            return self.delete_text(context, story_id, start, end);
753        }
754        let fields = crate::target::field_paragraphs(&self.package, &self.snapshot()?, story_id);
755        let source_xml = self.source_paragraph_xml(story_id)?;
756        let mut txn = self.transact_for(context);
757        let story = story_ref(&txn, story_id)?;
758        check_text_bounds(&story, &txn, start, end)?;
759        let final_index = final_pilcrow_index(&story, &txn)?;
760        if start <= final_index && final_index < end {
761            return Err(EditError::ParagraphBoundary { start, end });
762        }
763        let markers = paragraph_markers(&story, &txn);
764        let first = markers
765            .iter()
766            .position(|(index, _)| *index >= start)
767            .unwrap();
768        let last = markers.iter().position(|(index, _)| *index >= end).unwrap();
769        let properties = paragraph_projections(&story, &txn, story_id);
770        if first != last {
771            if properties[first..=last].iter().any(|(_, map, _)| {
772                map_string(map, &txn, PARA_ID).is_some_and(|id| fields.contains(&id))
773                    || matches!(map.get(&txn, "hasFields"), Some(Out::Any(Any::Bool(true))))
774            }) {
775                return Err(EditError::ParagraphBoundary { start, end });
776            }
777            let mut sources = Vec::new();
778            for (_, map, joined_sources) in &properties[first..=last] {
779                let paragraph_sources = if joined_sources.is_empty() {
780                    let id = map_string(map, &txn, PARA_ID).unwrap_or_default();
781                    let xml = map_string(map, &txn, "preservedXml").or_else(|| {
782                        let index = id
783                            .strip_prefix(&format!("para:{story_id}:"))?
784                            .parse::<usize>()
785                            .ok()?;
786                        source_xml.get(index).cloned()
787                    });
788                    vec![ParagraphSource { id, xml }]
789                } else {
790                    joined_sources.clone()
791                };
792                for source in paragraph_sources {
793                    if !sources
794                        .iter()
795                        .any(|previous: &ParagraphSource| previous.id == source.id)
796                    {
797                        sources.push(source);
798                    }
799                }
800            }
801            let source_json = serde_json::to_string(&sources)
802                .map_err(|error| EditError::Json(error.to_string()))?;
803            let values: Vec<_> = properties[first]
804                .1
805                .iter(&txn)
806                .filter_map(|(key, value)| match value {
807                    Out::Any(value) if key != "joinPosition" => Some((key.to_owned(), value)),
808                    _ => None,
809                })
810                .collect();
811            let position = story
812                .sticky_index(&txn, markers[first].0, Assoc::After)
813                .unwrap();
814            let supersedes = paragraph_join_records(&story, &txn, &markers)
815                .into_iter()
816                .filter_map(|(offset, key, _)| {
817                    let index = markers.iter().position(|(index, _)| *index >= offset)?;
818                    (first <= index && index <= last).then(|| Any::String(key.into()))
819                })
820                .collect();
821            let record = markers.last().unwrap().1.insert(
822                &mut txn,
823                self.next_id("join"),
824                MapPrelim::default(),
825            );
826            for (key, value) in values {
827                record.insert(&mut txn, key, value);
828            }
829            record.insert(
830                &mut txn,
831                "joinPosition",
832                Any::Buffer(position.encode_v1().into()),
833            );
834            record.insert(&mut txn, "joinSupersedes", Any::Array(supersedes));
835            record.insert(&mut txn, "sourceParagraphs", source_json);
836        }
837        let text = text_in_range(&story, &txn, start, end);
838        if end > start {
839            story.remove_range(&mut txn, start, end - start);
840        }
841        Ok(TextReceipt {
842            story_id: story_id.to_owned(),
843            start,
844            end,
845            text,
846        })
847    }
848
849    pub fn format_text(
850        &self,
851        context: &crate::EditCtx,
852        story_id: &str,
853        start: u32,
854        end: u32,
855        patch: &TextStylePatch,
856    ) -> EditResult<TextReceipt> {
857        validate_style_values(
858            patch.font_family.as_deref(),
859            patch.underline.as_deref(),
860            patch.color.as_deref(),
861            patch.font_size_pt,
862            patch.spacing_pt,
863            patch.baseline_pct,
864            None,
865        )?;
866        let mut txn = self.transact_for(context);
867        let story = story_ref(&txn, story_id)?;
868        check_text_bounds(&story, &txn, start, end)?;
869        let text = text_in_range(&story, &txn, start, end);
870        for (segment_start, segment_end) in paragraph_text_segments(&story, &txn, start, end) {
871            for (key, value) in patch_values(patch) {
872                if let Some(value) = value {
873                    story.format(
874                        &mut txn,
875                        segment_start,
876                        segment_end - segment_start,
877                        Attrs::from([(Arc::from(key), value)]),
878                    );
879                }
880            }
881        }
882        Ok(TextReceipt {
883            story_id: story_id.to_owned(),
884            start,
885            end,
886            text,
887        })
888    }
889
890    /// Sets `a:pPr@algn` on every paragraph the range touches; `None` clears
891    /// the value so the placeholder cascade applies again.
892    pub fn set_paragraph_alignment(
893        &self,
894        context: &crate::EditCtx,
895        story_id: &str,
896        start: u32,
897        end: u32,
898        alignment: Option<&str>,
899    ) -> EditResult<TextReceipt> {
900        validate_alignment(alignment)?;
901        let mut txn = self.transact_for(context);
902        let story = story_ref(&txn, story_id)?;
903        check_text_bounds(&story, &txn, start, end)?;
904        let text = text_in_range(&story, &txn, start, end);
905        let pilcrows = selected_pilcrows(&story, &txn, story_id, start, end);
906        for pilcrow in pilcrows {
907            match alignment {
908                Some(alignment) => {
909                    pilcrow.insert(&mut txn, "alignment", alignment);
910                }
911                None => {
912                    pilcrow.remove(&mut txn, "alignment");
913                }
914            }
915        }
916        Ok(TextReceipt {
917            story_id: story_id.to_owned(),
918            start,
919            end,
920            text,
921        })
922    }
923
924    pub fn insert_paragraph_break(
925        &self,
926        context: &crate::EditCtx,
927        story_id: &str,
928        index: u32,
929    ) -> EditResult<TextReceipt> {
930        let fields = crate::target::field_paragraphs(&self.package, &self.snapshot()?, story_id);
931        let (story, has_fields, inherited) = {
932            let txn = self.doc.transact();
933            let story = story_ref(&txn, story_id)?;
934            let final_pilcrow = final_pilcrow_index(&story, &txn)?;
935            if index > final_pilcrow {
936                return Err(EditError::OutOfBounds {
937                    index,
938                    length: final_pilcrow,
939                });
940            }
941            let (offset, properties, sources) = paragraph_projections(&story, &txn, story_id)
942                .into_iter()
943                .find(|(offset, _, _)| *offset >= index)
944                .unwrap();
945            let has_fields = map_string(&properties, &txn, PARA_ID)
946                .is_some_and(|id| fields.contains(&id))
947                || matches!(
948                    properties.get(&txn, "hasFields"),
949                    Some(Out::Any(Any::Bool(true)))
950                );
951            let marker = paragraph_markers(&story, &txn)
952                .into_iter()
953                .find(|(position, _)| *position == offset)
954                .unwrap()
955                .1;
956            let inherited = if marker == properties {
957                None
958            } else {
959                let sources = serde_json::to_string(&sources)
960                    .map_err(|error| EditError::Json(error.to_string()))?;
961                Some((marker, properties, sources))
962            };
963            (story, has_fields, inherited)
964        };
965        let paragraph_id = self.next_id("para");
966        let mut txn = self.transact_for(context);
967        let pilcrow = story.insert_embed_with_attributes(
968            &mut txn,
969            index,
970            MapPrelim::default(),
971            Attrs::default(),
972        );
973        pilcrow.insert(&mut txn, KIND, PILCROW_KIND);
974        pilcrow.insert(&mut txn, PARA_ID, paragraph_id);
975        pilcrow.insert(&mut txn, "level", 0_f64);
976        if has_fields {
977            pilcrow.insert(&mut txn, "hasFields", true);
978        }
979        if let Some((marker, properties, sources)) = inherited {
980            for target in [marker, pilcrow] {
981                for key in ["alignment", "level", "bulletJson", "preservedXml"] {
982                    match properties.get(&txn, key) {
983                        Some(Out::Any(value)) => {
984                            target.insert(&mut txn, key, value);
985                        }
986                        _ => {
987                            target.remove(&mut txn, key);
988                        }
989                    }
990                }
991                target.insert(&mut txn, "sourceParagraphs", sources.as_str());
992            }
993        }
994        Ok(TextReceipt {
995            story_id: story_id.to_owned(),
996            start: index,
997            end: index + 1,
998            text: "\n".to_owned(),
999        })
1000    }
1001
1002    pub fn delete_paragraph_break(
1003        &self,
1004        context: &crate::EditCtx,
1005        story_id: &str,
1006        index: u32,
1007    ) -> EditResult<TextReceipt> {
1008        let txn = self.transact_for(context);
1009        let story = story_ref(&txn, story_id)?;
1010        let final_pilcrow = final_pilcrow_index(&story, &txn)?;
1011        if index >= final_pilcrow {
1012            return Err(EditError::OutOfBounds {
1013                index,
1014                length: final_pilcrow,
1015            });
1016        }
1017        let mut offset = 0;
1018        let is_pilcrow = story.diff(&txn, YChange::identity).into_iter().any(|diff| {
1019            let length = out_len(&diff.insert);
1020            let found = offset == index
1021                && matches!(
1022                    diff.insert,
1023                    Out::YMap(ref map)
1024                        if map_string(map, &txn, KIND).as_deref() == Some(PILCROW_KIND)
1025                );
1026            offset += length;
1027            found
1028        });
1029        if !is_pilcrow {
1030            return Err(EditError::ParagraphBoundary {
1031                start: index,
1032                end: index.saturating_add(1),
1033            });
1034        }
1035        drop(txn);
1036        self.delete_text_joining_paragraphs(context, story_id, index, index + 1)
1037    }
1038
1039    fn remove_replacement_paragraph(
1040        &self,
1041        context: &crate::EditCtx,
1042        story_id: &str,
1043        index: u32,
1044        empty_after: bool,
1045    ) -> EditResult<()> {
1046        let fields = crate::target::field_paragraphs(&self.package, &self.snapshot()?, story_id);
1047        let mut txn = self.transact_for(context);
1048        let story = story_ref(&txn, story_id)?;
1049        let markers = paragraph_markers(&story, &txn);
1050        let selected = markers
1051            .iter()
1052            .position(|(offset, _)| *offset == index)
1053            .ok_or(EditError::ParagraphBoundary {
1054                start: index,
1055                end: index + 1,
1056            })?;
1057        let start = selected
1058            .checked_sub(1)
1059            .map_or(0, |previous| markers[previous].0 + 1);
1060        let properties = paragraph_projections(&story, &txn, story_id);
1061        let empty = if empty_after {
1062            markers
1063                .get(selected + 1)
1064                .is_some_and(|(offset, _)| *offset == index + 1)
1065        } else {
1066            start == index
1067        };
1068        if !empty
1069            || selected + 1 >= markers.len()
1070            || properties[selected..=selected + 1]
1071                .iter()
1072                .any(|(_, map, _)| {
1073                    map_string(map, &txn, PARA_ID).is_some_and(|id| fields.contains(&id))
1074                        || matches!(map.get(&txn, "hasFields"), Some(Out::Any(Any::Bool(true))))
1075                })
1076        {
1077            return Err(EditError::ParagraphBoundary {
1078                start: index,
1079                end: index + 1,
1080            });
1081        }
1082        story.remove_range(&mut txn, index, 1);
1083        Ok(())
1084    }
1085}
1086
1087pub(crate) fn validate_story<T: ReadTxn>(
1088    story: &TextRef,
1089    txn: &T,
1090    story_id: &str,
1091) -> EditResult<()> {
1092    let mut offset = 0;
1093    let mut pilcrows = Vec::new();
1094    for diff in story.diff(txn, YChange::identity) {
1095        if let Some(kern) = diff.attributes.as_ref().and_then(|attrs| attrs.get("kern"))
1096            && !valid_kern(kern)
1097        {
1098            return Err(EditError::InvalidState(format!(
1099                "story {story_id} has a kern threshold outside 0-4000pt"
1100            )));
1101        }
1102        let length = out_len(&diff.insert);
1103        if let Out::YMap(map) = diff.insert {
1104            if map_string(&map, txn, KIND).as_deref() != Some(PILCROW_KIND) {
1105                return Err(EditError::InvalidState(format!(
1106                    "story {story_id} contains a non-pilcrow embed"
1107                )));
1108            }
1109            pilcrows.push(offset);
1110        }
1111        offset += length;
1112    }
1113    if pilcrows.last().copied() != story.len(txn).checked_sub(1) {
1114        return Err(EditError::InvalidState(format!(
1115            "story {story_id} has no final pilcrow"
1116        )));
1117    }
1118    Ok(())
1119}
1120
1121/// A remote `kern` lands in a schema-typed attribute, so it must be a size.
1122fn valid_kern(value: &Any) -> bool {
1123    match value {
1124        Any::Null => true,
1125        Any::Number(kern) => (0.0..=4_000.0).contains(kern),
1126        Any::BigInt(kern) => (0..=4_000).contains(kern),
1127        _ => false,
1128    }
1129}
1130
1131/// The snapshot `snapshot_story` reads back from a story `seed_story` wrote,
1132/// computed without materializing a scratch document.
1133pub fn baseline_story(
1134    story_id: &str,
1135    body: &TextBody,
1136    theme: Option<&Theme>,
1137) -> EditResult<StorySnapshot> {
1138    let mut paragraphs = Vec::with_capacity(body.paragraphs.len().max(1));
1139    let mut length = 1_u32;
1140    if body.paragraphs.is_empty() {
1141        paragraphs.push(ParagraphSnapshot {
1142            source_paragraphs: Vec::new(),
1143            id: format!("para:{story_id}:0"),
1144            preserved_xml: None,
1145            alignment: None,
1146            level: 0,
1147            bullet_json: None,
1148            runs: Vec::new(),
1149        });
1150    } else {
1151        length = 0;
1152        for (paragraph_index, paragraph) in body.paragraphs.iter().enumerate() {
1153            let mut runs: Vec<TextRunSnapshot> = Vec::new();
1154            let mut last_attrs: Option<TextStyle> = None;
1155            for run in &paragraph.runs {
1156                if run.text.is_empty() {
1157                    continue;
1158                }
1159                let style = style_from_run_properties(&run.properties, theme);
1160                // yrs' format-gap cleanup deletes every marker between two
1161                // identically styled runs, so their text items squash into a
1162                // single run in the snapshot.
1163                if last_attrs.as_ref() == Some(&style) {
1164                    if let Some(last) = runs.last_mut() {
1165                        last.text.push_str(&run.text);
1166                    }
1167                } else {
1168                    runs.push(TextRunSnapshot {
1169                        text: run.text.clone(),
1170                        style: baseline_style(style.clone()),
1171                    });
1172                }
1173                last_attrs = Some(style);
1174                length += run.text.encode_utf16().count() as u32;
1175            }
1176            length += 1;
1177            let bullet_json = paragraph
1178                .properties
1179                .bullet
1180                .as_ref()
1181                .map(serde_json::to_string)
1182                .transpose()
1183                .map_err(|error| EditError::Json(error.to_string()))?;
1184            paragraphs.push(ParagraphSnapshot {
1185                source_paragraphs: Vec::new(),
1186                id: format!("para:{story_id}:{paragraph_index}"),
1187                preserved_xml: None,
1188                alignment: paragraph.properties.alignment.clone(),
1189                level: paragraph.properties.level,
1190                bullet_json,
1191                runs,
1192            });
1193        }
1194    }
1195    Ok(StorySnapshot {
1196        id: story_id.to_owned(),
1197        length,
1198        paragraphs,
1199    })
1200}
1201
1202/// Attribute reads drop non-finite numbers, so the snapshot applies the same
1203/// normalization the seeded `Any::Number` attributes go through.
1204fn baseline_style(style: TextStyle) -> TextStyle {
1205    TextStyle {
1206        font_size_pt: style.font_size_pt.filter(|value| value.is_finite()),
1207        spacing_pt: style.spacing_pt.filter(|value| value.is_finite()),
1208        baseline_pct: style.baseline_pct.filter(|value| value.is_finite()),
1209        kern_pt: style.kern_pt.filter(|value| value.is_finite()),
1210        ..style
1211    }
1212}
1213
1214pub(crate) fn snapshot_story<T: ReadTxn>(
1215    story: &TextRef,
1216    txn: &T,
1217    story_id: &str,
1218) -> EditResult<StorySnapshot> {
1219    validate_story(story, txn, story_id)?;
1220    let mut paragraphs = Vec::new();
1221    let mut runs = Vec::new();
1222    let storage_key = crate::table::storage_key(txn, story_id)?;
1223    let projections = paragraph_projections(story, txn, story_id);
1224    for diff in story.diff(txn, YChange::identity) {
1225        match diff.insert {
1226            Out::Any(Any::String(text)) => runs.push(TextRunSnapshot {
1227                text: text.to_string(),
1228                style: style_from_attrs(diff.attributes.as_deref()),
1229            }),
1230            Out::YMap(_) => {
1231                let (_, map, sources) = &projections[paragraphs.len()];
1232                if runs.is_empty()
1233                    && let Some(json) = map_string(map, txn, "emptyStyleJson")
1234                {
1235                    runs.push(TextRunSnapshot {
1236                        text: String::new(),
1237                        style: serde_json::from_str(&json)
1238                            .map_err(|error| EditError::InvalidState(error.to_string()))?,
1239                    });
1240                }
1241                paragraphs.push(ParagraphSnapshot {
1242                    source_paragraphs: sources
1243                        .iter()
1244                        .map(|source| ParagraphSource {
1245                            id: crate::table::project_paragraph_id(
1246                                &source.id,
1247                                &storage_key,
1248                                story_id,
1249                            ),
1250                            xml: source.xml.clone(),
1251                        })
1252                        .collect(),
1253                    id: crate::table::project_paragraph_id(
1254                        &map_string(map, txn, PARA_ID).unwrap_or_default(),
1255                        &storage_key,
1256                        story_id,
1257                    ),
1258                    preserved_xml: map_string(map, txn, "preservedXml"),
1259                    alignment: map_string(map, txn, "alignment"),
1260                    level: map_number(map, txn, "level").unwrap_or_default() as u32,
1261                    bullet_json: map_string(map, txn, "bulletJson"),
1262                    runs: std::mem::take(&mut runs),
1263                });
1264            }
1265            _ => {}
1266        }
1267    }
1268    Ok(StorySnapshot {
1269        id: story_id.to_owned(),
1270        length: story.len(txn),
1271        paragraphs,
1272    })
1273}
1274
1275pub(crate) fn story_ref<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<TextRef> {
1276    if story_id.starts_with("cell:") {
1277        return Err(EditError::StoryNotFound(story_id.into()));
1278    }
1279    let key = crate::table::storage_key(txn, story_id)?;
1280    txn.get_map(STORIES)
1281        .and_then(|stories| stories.get(txn, &key))
1282        .and_then(|value| value.cast::<TextRef>().ok())
1283        .ok_or_else(|| EditError::StoryNotFound(story_id.to_owned()))
1284}
1285
1286fn final_pilcrow_index<T: ReadTxn>(story: &TextRef, txn: &T) -> EditResult<u32> {
1287    validate_story(story, txn, "requested")?;
1288    Ok(story.len(txn) - 1)
1289}
1290
1291fn check_text_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
1292    check_text_bounds(story, txn, start, end)?;
1293    for (index, _) in paragraph_markers(story, txn) {
1294        if start <= index && index < end {
1295            return Err(EditError::ParagraphBoundary { start, end });
1296        }
1297    }
1298    Ok(())
1299}
1300
1301fn check_text_bounds<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> EditResult<()> {
1302    let length = story.len(txn);
1303    if start > end || end > length {
1304        return Err(EditError::OutOfBounds {
1305            index: end.max(start),
1306            length,
1307        });
1308    }
1309    Ok(())
1310}
1311
1312/// Whether a paragraph-level edit over `start..end` touches the paragraph spanning
1313/// `paragraph_start..=pilcrow`. A collapsed caret picks the paragraph it sits in; a range
1314/// stopping at a paragraph start does not.
1315pub(crate) fn selects_paragraph(start: u32, end: u32, paragraph_start: u32, pilcrow: u32) -> bool {
1316    start <= pilcrow && (end > paragraph_start || (start == end && start >= paragraph_start))
1317}
1318
1319/// The pilcrow of every paragraph the range touches, as [`selects_paragraph`] decides.
1320fn selected_pilcrows<T: ReadTxn>(
1321    story: &TextRef,
1322    txn: &T,
1323    story_id: &str,
1324    start: u32,
1325    end: u32,
1326) -> Vec<MapRef> {
1327    let start = start.min(story.len(txn).saturating_sub(1));
1328    let mut pilcrows = Vec::new();
1329    let mut paragraph_start = 0;
1330    for (offset, map, _) in paragraph_projections(story, txn, story_id) {
1331        if selects_paragraph(start, end, paragraph_start, offset) {
1332            pilcrows.push(map);
1333        }
1334        paragraph_start = offset + 1;
1335    }
1336    pilcrows
1337}
1338
1339fn paragraph_markers<T: ReadTxn>(story: &TextRef, txn: &T) -> Vec<(u32, MapRef)> {
1340    let mut markers = Vec::new();
1341    let mut offset = 0;
1342    for diff in story.diff(txn, YChange::identity) {
1343        let length = out_len(&diff.insert);
1344        if let Out::YMap(map) = diff.insert {
1345            markers.push((offset, map));
1346        }
1347        offset += length;
1348    }
1349    markers
1350}
1351
1352fn source_paragraphs<T: ReadTxn>(map: &MapRef, txn: &T) -> Vec<ParagraphSource> {
1353    map_string(map, txn, "sourceParagraphs")
1354        .and_then(|json| serde_json::from_str(&json).ok())
1355        .unwrap_or_default()
1356}
1357
1358fn paragraph_projections<T: ReadTxn>(
1359    story: &TextRef,
1360    txn: &T,
1361    story_id: &str,
1362) -> Vec<(u32, MapRef, Vec<ParagraphSource>)> {
1363    let storage_key = crate::table::storage_key(txn, story_id).unwrap_or_else(|_| story_id.into());
1364    let story_id = storage_key.as_str();
1365    let markers = paragraph_markers(story, txn);
1366    let mut result: Vec<_> = markers
1367        .iter()
1368        .map(|(offset, map)| {
1369            let sources = if map.contains_key(txn, "sourceParagraphs") {
1370                source_paragraphs(map, txn)
1371            } else {
1372                Vec::new()
1373            };
1374            (*offset, map.clone(), sources)
1375        })
1376        .collect();
1377    let mut joins = paragraph_join_records(story, txn, &markers);
1378    let superseded: std::collections::HashSet<_> = joins
1379        .iter()
1380        .flat_map(|(_, _, map)| match map.get(txn, "joinSupersedes") {
1381            Some(Out::Any(Any::Array(keys))) => keys
1382                .iter()
1383                .filter_map(|key| match key {
1384                    Any::String(key) => Some(key.to_string()),
1385                    _ => None,
1386                })
1387                .collect::<Vec<_>>(),
1388            _ => Vec::new(),
1389        })
1390        .collect();
1391    joins.retain(|(_, key, _)| !superseded.contains(key));
1392    joins.sort_by_key(|(offset, key, map)| {
1393        (
1394            *offset,
1395            source_paragraphs(map, txn).first().and_then(|source| {
1396                source
1397                    .id
1398                    .strip_prefix(&format!("para:{story_id}:"))?
1399                    .parse::<usize>()
1400                    .ok()
1401            }),
1402            key.clone(),
1403        )
1404    });
1405    let mut projected = vec![false; result.len()];
1406    for (offset, _, map) in joins {
1407        let Some(index) = markers.iter().position(|(index, _)| *index >= offset) else {
1408            continue;
1409        };
1410        if !projected[index] {
1411            result[index].1 = map.clone();
1412            projected[index] = true;
1413        }
1414        for source in source_paragraphs(&map, txn) {
1415            if !result[index]
1416                .2
1417                .iter()
1418                .any(|previous| previous.id == source.id)
1419            {
1420                result[index].2.push(source);
1421            }
1422        }
1423    }
1424    result
1425}
1426
1427pub(crate) fn materialize_paragraph_joins(
1428    target: &TextRef,
1429    txn: &mut TransactionMut<'_>,
1430    source_id: &str,
1431) -> EditResult<()> {
1432    let source = story_ref(txn, source_id)?;
1433    let markers = paragraph_markers(&source, txn);
1434    if paragraph_join_records(&source, txn, &markers).is_empty() {
1435        return Ok(());
1436    }
1437    let projections = paragraph_projections(&source, txn, source_id);
1438    for ((_, target), (_, properties, sources)) in
1439        paragraph_markers(target, txn).into_iter().zip(projections)
1440    {
1441        let values: Vec<_> = properties
1442            .iter(txn)
1443            .filter_map(|(key, value)| match value {
1444                Out::Any(value) if !matches!(key, "joinPosition" | "joinSupersedes") => {
1445                    Some((key.to_owned(), value))
1446                }
1447                _ => None,
1448            })
1449            .collect();
1450        target.clear(txn);
1451        for (key, value) in values {
1452            target.insert(txn, key, value);
1453        }
1454        if !sources.is_empty() {
1455            let json = serde_json::to_string(&sources)
1456                .map_err(|error| EditError::Json(error.to_string()))?;
1457            target.insert(txn, "sourceParagraphs", json);
1458        }
1459    }
1460    Ok(())
1461}
1462
1463fn paragraph_join_records<T: ReadTxn>(
1464    story: &TextRef,
1465    txn: &T,
1466    markers: &[(u32, MapRef)],
1467) -> Vec<(u32, String, MapRef)> {
1468    let Some((_, last)) = markers.last() else {
1469        return Vec::new();
1470    };
1471    let branch = BranchPtr::from(<TextRef as AsRef<Branch>>::as_ref(story));
1472    last.iter(txn)
1473        .filter_map(|(key, value)| {
1474            if !key.starts_with("join:") {
1475                return None;
1476            }
1477            let Out::YMap(map) = value else { return None };
1478            let Some(Out::Any(Any::Buffer(bytes))) = map.get(txn, "joinPosition") else {
1479                return None;
1480            };
1481            let position = StickyIndex::decode_v1(&bytes).ok()?.get_offset(txn)?;
1482            (position.branch == branch).then(|| (position.index, key.to_owned(), map))
1483        })
1484        .collect()
1485}
1486
1487fn paragraph_text_segments<T: ReadTxn>(
1488    story: &TextRef,
1489    txn: &T,
1490    start: u32,
1491    end: u32,
1492) -> Vec<(u32, u32)> {
1493    let mut segments = Vec::new();
1494    let mut paragraph_start = 0;
1495    let mut offset = 0;
1496    for diff in story.diff(txn, YChange::identity) {
1497        let item_length = out_len(&diff.insert);
1498        if matches!(diff.insert, Out::YMap(_)) {
1499            let segment_start = start.max(paragraph_start);
1500            let segment_end = end.min(offset);
1501            if segment_start < segment_end {
1502                segments.push((segment_start, segment_end));
1503            }
1504            paragraph_start = offset + item_length;
1505        }
1506        offset += item_length;
1507    }
1508    segments
1509}
1510
1511fn text_in_range<T: ReadTxn>(story: &TextRef, txn: &T, start: u32, end: u32) -> String {
1512    let mut output = String::new();
1513    let mut offset = 0;
1514    for diff in story.diff(txn, YChange::identity) {
1515        let length = out_len(&diff.insert);
1516        if let Out::Any(Any::String(ref text)) = diff.insert {
1517            let overlap_start = start.saturating_sub(offset).min(length);
1518            let overlap_end = end.saturating_sub(offset).min(length);
1519            if overlap_end > overlap_start {
1520                let utf16 = text.encode_utf16().collect::<Vec<_>>();
1521                output.push_str(&String::from_utf16_lossy(
1522                    &utf16[overlap_start as usize..overlap_end as usize],
1523                ));
1524            }
1525        } else if matches!(diff.insert, Out::YMap(_))
1526            && start <= offset
1527            && offset < end
1528            && offset < story.len(txn) - 1
1529        {
1530            output.push('\n');
1531        }
1532        offset += length;
1533        if offset >= end {
1534            break;
1535        }
1536    }
1537    output
1538}
1539
1540pub(crate) fn insert_styled_text(
1541    story: &TextRef,
1542    txn: &mut TransactionMut<'_>,
1543    index: u32,
1544    text: &str,
1545    style: &TextStyle,
1546) {
1547    story.insert(txn, index, text);
1548    let length = text.encode_utf16().count() as u32;
1549    for (key, value) in style_values(style) {
1550        story.format(txn, index, length, Attrs::from([(Arc::from(key), value)]));
1551    }
1552}
1553
1554fn style_values(style: &TextStyle) -> [(&'static str, Any); 10] {
1555    [
1556        ("bold", style.bold.map(Any::Bool).unwrap_or(Any::Null)),
1557        ("italic", style.italic.map(Any::Bool).unwrap_or(Any::Null)),
1558        (
1559            "fontSize",
1560            style.font_size_pt.map(Any::Number).unwrap_or(Any::Null),
1561        ),
1562        (
1563            "color",
1564            style.color.as_deref().map(Any::from).unwrap_or(Any::Null),
1565        ),
1566        (
1567            "fontFamily",
1568            style
1569                .font_family
1570                .as_deref()
1571                .map(Any::from)
1572                .unwrap_or(Any::Null),
1573        ),
1574        (
1575            "underline",
1576            style
1577                .underline
1578                .as_deref()
1579                .map(Any::from)
1580                .unwrap_or(Any::Null),
1581        ),
1582        (
1583            "spacing",
1584            style.spacing_pt.map(Any::Number).unwrap_or(Any::Null),
1585        ),
1586        (
1587            "baseline",
1588            style.baseline_pct.map(Any::Number).unwrap_or(Any::Null),
1589        ),
1590        ("kern", style.kern_pt.map(Any::Number).unwrap_or(Any::Null)),
1591        (
1592            "caps",
1593            style
1594                .caps
1595                .map(|caps| Any::from(caps.as_attribute()))
1596                .unwrap_or(Any::Null),
1597        ),
1598    ]
1599}
1600
1601fn patch_values(patch: &TextStylePatch) -> [(&'static str, Option<Any>); 8] {
1602    [
1603        ("bold", patch.bold.map(Any::Bool)),
1604        ("italic", patch.italic.map(Any::Bool)),
1605        ("fontSize", patch.font_size_pt.map(Any::Number)),
1606        ("color", patch.color.as_deref().map(Any::from)),
1607        ("fontFamily", patch.font_family.as_deref().map(Any::from)),
1608        ("underline", patch.underline.as_deref().map(Any::from)),
1609        ("spacing", patch.spacing_pt.map(Any::Number)),
1610        ("baseline", patch.baseline_pct.map(Any::Number)),
1611    ]
1612}
1613
1614pub(crate) fn style_from_run_properties(
1615    properties: &RunProperties,
1616    theme: Option<&Theme>,
1617) -> TextStyle {
1618    TextStyle {
1619        bold: properties.bold,
1620        italic: properties.italic,
1621        font_size_pt: properties.font_size_pt,
1622        color: resolve_color_value_to_hex_with_theme(properties.color.as_ref(), theme),
1623        font_family: properties.font_family.clone(),
1624        underline: properties.underline.clone(),
1625        spacing_pt: properties.spacing_pt,
1626        baseline_pct: properties.baseline_pct,
1627        kern_pt: properties.kern_pt,
1628        caps: properties.caps,
1629    }
1630}
1631
1632pub(crate) fn style_from_attrs(attrs: Option<&Attrs>) -> TextStyle {
1633    TextStyle {
1634        bold: attrs.and_then(|attrs| any_bool(attrs.get("bold"))),
1635        italic: attrs.and_then(|attrs| any_bool(attrs.get("italic"))),
1636        font_size_pt: attrs.and_then(|attrs| any_number(attrs.get("fontSize"))),
1637        color: attrs.and_then(|attrs| any_string(attrs.get("color"))),
1638        font_family: attrs.and_then(|attrs| any_string(attrs.get("fontFamily"))),
1639        underline: attrs.and_then(|attrs| any_string(attrs.get("underline"))),
1640        spacing_pt: attrs.and_then(|attrs| any_number(attrs.get("spacing"))),
1641        baseline_pct: attrs.and_then(|attrs| any_number(attrs.get("baseline"))),
1642        kern_pt: attrs.and_then(|attrs| any_number(attrs.get("kern"))),
1643        caps: attrs
1644            .and_then(|attrs| any_string(attrs.get("caps")))
1645            .as_deref()
1646            .and_then(TextCaps::from_attribute),
1647    }
1648}
1649
1650fn any_bool(value: Option<&Any>) -> Option<bool> {
1651    match value {
1652        Some(Any::Bool(value)) => Some(*value),
1653        _ => None,
1654    }
1655}
1656
1657fn any_number(value: Option<&Any>) -> Option<f64> {
1658    match value {
1659        Some(Any::Number(value)) if value.is_finite() => Some(*value),
1660        Some(Any::BigInt(value)) => Some(*value as f64),
1661        _ => None,
1662    }
1663}
1664
1665fn any_string(value: Option<&Any>) -> Option<String> {
1666    match value {
1667        Some(Any::String(value)) => Some(value.to_string()),
1668        _ => None,
1669    }
1670}
1671
1672fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
1673    match map.get(txn, key) {
1674        Some(Out::Any(Any::String(value))) => Some(value.to_string()),
1675        _ => None,
1676    }
1677}
1678
1679fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
1680    match map.get(txn, key) {
1681        Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
1682        Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
1683        _ => None,
1684    }
1685}
1686
1687fn out_len(value: &Out) -> u32 {
1688    match value {
1689        Out::Any(Any::String(value)) => value.encode_utf16().count() as u32,
1690        _ => 1,
1691    }
1692}