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
44pub(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 ¶graph.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 ¶graph.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(¤t);
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(¤t);
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 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
1121fn 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
1131pub 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 ¶graph.runs {
1156 if run.text.is_empty() {
1157 continue;
1158 }
1159 let style = style_from_run_properties(&run.properties, theme);
1160 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
1202fn 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
1312pub(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
1319fn 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}