diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 6b0c60e2a1065ba09c76ee8506392396d9faee4c..350cb23d36dbd13cd949cff29e7b49e443326ccb 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -671,6 +671,7 @@ impl Lowering<'_> { if stored.text.text() != text.text.text() { return Err(invalid("Text edits did not converge on the model")); } + let fresh = self.alias.contains_key(&text.id); let mut boundaries = BTreeSet::new(); for paragraph in [&stored.text, &text.text] { for span in paragraph.spans() { @@ -689,6 +690,7 @@ impl Lowering<'_> { let attributes = attributes( format_in(&stored.text, start)?, format_in(&text.text, start)?, + fresh, )?; match &mut pending { Some((range, previous)) if *previous == attributes && range.end == start => { @@ -704,8 +706,11 @@ impl Lowering<'_> { } edits.extend(pending); if text.text.text().is_empty() { - let attributes = - attributes(format_in(&stored.text, 0)?, format_in(&text.text, 0)?)?; + let attributes = attributes( + format_in(&stored.text, 0)?, + format_in(&text.text, 0)?, + fresh, + )?; if !attributes.is_empty() { edits.push((0..0, attributes)); } @@ -900,17 +905,19 @@ fn text_edit(before: &str, after: &str) -> Result<(Range, String), Error> { } /// Explicit attributes turning `current` into `target`; unsupported differences are errors. +/// A `fresh` text object was inserted by this edit, so an unspecified target value keeps +/// the insertion's default instead of demanding an inherited value the image cannot restore. /// Language tags are retained as stored because the model has no way to author them. -fn attributes(current: &Format, target: &Format) -> Result, Error> { +fn attributes(current: &Format, target: &Format, fresh: bool) -> Result, Error> { let mut out = Vec::new(); + let inherited = || invalid("Inherited character formatting cannot be restored"); macro_rules! boolean { ($field:ident, $variant:ident) => { if current.$field != target.$field { match target.$field { Some(value) => out.push(TextAttribute::$variant(value)), - None => { - return Err(invalid("Inherited character formatting cannot be restored")); - } + None if fresh => {} + None => return Err(inherited()), } } }; @@ -924,13 +931,15 @@ fn attributes(current: &Format, target: &Format) -> Result, E if current.font != target.font { match &target.font { Some(font) => out.push(TextAttribute::Font(font.clone())), - None => return Err(invalid("Inherited character formatting cannot be restored")), + None if fresh => {} + None => return Err(inherited()), } } if current.font_size != target.font_size { match target.font_size { Some(size) => out.push(TextAttribute::FontSize(size)), - None => return Err(invalid("Inherited character formatting cannot be restored")), + None if fresh => {} + None => return Err(inherited()), } } let color = @@ -938,13 +947,15 @@ fn attributes(current: &Format, target: &Format) -> Result, E if current.color != target.color { match target.color { Some(value) => out.push(TextAttribute::Color(color(value))), - None => return Err(invalid("Inherited character formatting cannot be restored")), + None if fresh => {} + None => return Err(inherited()), } } if current.highlight != target.highlight { match target.highlight { Some(value) => out.push(TextAttribute::Highlight(color(value))), - None => return Err(invalid("Inherited character formatting cannot be restored")), + None if fresh => {} + None => return Err(inherited()), } } // An absent value and its stored default are the same formatting. diff --git a/crates/onestore/tests/page_writer.rs b/crates/onestore/tests/page_writer.rs index 69b3e6252676e604e7c7fcd935145b172f77ad7e..f842bd485bb4d470740e955ba93fe34e3ed405c1 100644 --- a/crates/onestore/tests/page_writer.rs +++ b/crates/onestore/tests/page_writer.rs @@ -621,6 +621,56 @@ fn unsupported_model_edits_are_rejected_before_writing() { assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err()); } +#[test] +fn a_new_paragraph_with_unspecified_formatting_takes_the_insertion_default() { + let (space, mut page) = page_by_title(OUTLINES, "Move leaf down"); + let text = onestore::page::TextObject { + id: new_id().unwrap(), + date_field: None, + text: onestore::page::Paragraph::new("Plain 東京".into(), Default::default()), + tags: Vec::new(), + }; + let mut paragraph = body(&page)[0].paragraphs[0].clone(); + paragraph.id = new_id().unwrap(); + paragraph.parent = None; + paragraph.level = 1; + paragraph.lists.clear(); + paragraph.tags.clear(); + paragraph.style = None; + paragraph.collapsed = false; + paragraph.content = onestore::page::ParagraphContent::Text(text.clone()); + let outline = onestore::page::Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: onestore::document::Layout { + x: Some(300.0), + y: Some(500.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![paragraph], + unsupported: Vec::new(), + }; + page.objects.insert(0, PageObject::Outline(outline)); + let written = PreparedEdit::page(OUTLINES, space, &page, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let read_back = body(&stored) + .iter() + .flat_map(|outline| outline.paragraphs.iter()) + .find_map(|p| p.text().filter(|t| t.id == text.id)) + .unwrap(); + assert_eq!(read_back.text.text(), "Plain 東京"); + let format = read_back.text.format_at(0).unwrap(); + assert!(format.font.is_some() && format.font_size.is_some()); + assert_eq!( + PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + .unwrap() + .as_bytes(), + written.as_bytes() + ); +} + #[test] fn an_unchanged_model_publishes_nothing() { let (space, page) = page_by_title(OUTLINES, "Resize outline");