authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 03:10:11-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
log2209a77660d46fe4a034fd72298b65f3201a9118
tree84ddefdc45eea15e7fc982ad0f4a99368ee303b9
parentcc5b6a1bca8a59e2c06208c9086e7b002f0e95df
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: let new paragraphs leave unspecified formatting to the insertion default

A text object the page writer inserts starts with the insertion default style (Calibri 11), and its formatting pass then compared that stored style with the model. A model span with unspecified font, size, colour, highlight or boolean attributes, as a fresh paragraph built from Format::default() or read back from a writer-minted cell replacement carries, was refused as inherited formatting the image cannot restore. For a text object this edit inserted an unspecified value now keeps the insertion default; stored objects still refuse to lose an explicit value. Both cases surfaced while re-expressing the notebook tests on page saves: a new outline had to copy a resolved format from an existing paragraph, and an edit to an emptied cell's replacement paragraph could not be published once it was merged against a remote page that lacked the replacement. Assisted-by: claude-fable-5.1

2 files changed, 71 insertions(+), 10 deletions(-)

crates/onestore/src/page/write.rs+21-10
......@@ -671,6 +671,7 @@ impl Lowering<'_> {
671671 if stored.text.text() != text.text.text() {
672672 return Err(invalid("Text edits did not converge on the model"));
673673 }
674 let fresh = self.alias.contains_key(&text.id);
674675 let mut boundaries = BTreeSet::new();
675676 for paragraph in [&stored.text, &text.text] {
676677 for span in paragraph.spans() {
......@@ -689,6 +690,7 @@ impl Lowering<'_> {
689690 let attributes = attributes(
690691 format_in(&stored.text, start)?,
691692 format_in(&text.text, start)?,
693 fresh,
692694 )?;
693695 match &mut pending {
694696 Some((range, previous)) if *previous == attributes && range.end == start => {
......@@ -704,8 +706,11 @@ impl Lowering<'_> {
704706 }
705707 edits.extend(pending);
706708 if text.text.text().is_empty() {
707 let attributes =
708 attributes(format_in(&stored.text, 0)?, format_in(&text.text, 0)?)?;
709 let attributes = attributes(
710 format_in(&stored.text, 0)?,
711 format_in(&text.text, 0)?,
712 fresh,
713 )?;
709714 if !attributes.is_empty() {
710715 edits.push((0..0, attributes));
711716 }
......@@ -900,17 +905,19 @@ fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> {
900905}
901906
902907/// Explicit attributes turning `current` into `target`; unsupported differences are errors.
908/// A `fresh` text object was inserted by this edit, so an unspecified target value keeps
909/// the insertion's default instead of demanding an inherited value the image cannot restore.
903910/// Language tags are retained as stored because the model has no way to author them.
904fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, Error> {
911fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<TextAttribute>, Error> {
905912 let mut out = Vec::new();
913 let inherited = || invalid("Inherited character formatting cannot be restored");
906914 macro_rules! boolean {
907915 ($field:ident, $variant:ident) => {
908916 if current.$field != target.$field {
909917 match target.$field {
910918 Some(value) => out.push(TextAttribute::$variant(value)),
911 None => {
912 return Err(invalid("Inherited character formatting cannot be restored"));
913 }
919 None if fresh => {}
920 None => return Err(inherited()),
914921 }
915922 }
916923 };
......@@ -924,13 +931,15 @@ fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, E
924931 if current.font != target.font {
925932 match &target.font {
926933 Some(font) => out.push(TextAttribute::Font(font.clone())),
927 None => return Err(invalid("Inherited character formatting cannot be restored")),
934 None if fresh => {}
935 None => return Err(inherited()),
928936 }
929937 }
930938 if current.font_size != target.font_size {
931939 match target.font_size {
932940 Some(size) => out.push(TextAttribute::FontSize(size)),
933 None => return Err(invalid("Inherited character formatting cannot be restored")),
941 None if fresh => {}
942 None => return Err(inherited()),
934943 }
935944 }
936945 let color =
......@@ -938,13 +947,15 @@ fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, E
938947 if current.color != target.color {
939948 match target.color {
940949 Some(value) => out.push(TextAttribute::Color(color(value))),
941 None => return Err(invalid("Inherited character formatting cannot be restored")),
950 None if fresh => {}
951 None => return Err(inherited()),
942952 }
943953 }
944954 if current.highlight != target.highlight {
945955 match target.highlight {
946956 Some(value) => out.push(TextAttribute::Highlight(color(value))),
947 None => return Err(invalid("Inherited character formatting cannot be restored")),
957 None if fresh => {}
958 None => return Err(inherited()),
948959 }
949960 }
950961 // An absent value and its stored default are the same formatting.
crates/onestore/tests/page_writer.rs+50
......@@ -621,6 +621,56 @@ fn unsupported_model_edits_are_rejected_before_writing() {
621621 assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err());
622622}
623623
624#[test]
625fn a_new_paragraph_with_unspecified_formatting_takes_the_insertion_default() {
626 let (space, mut page) = page_by_title(OUTLINES, "Move leaf down");
627 let text = onestore::page::TextObject {
628 id: new_id().unwrap(),
629 date_field: None,
630 text: onestore::page::Paragraph::new("Plain 東京".into(), Default::default()),
631 tags: Vec::new(),
632 };
633 let mut paragraph = body(&page)[0].paragraphs[0].clone();
634 paragraph.id = new_id().unwrap();
635 paragraph.parent = None;
636 paragraph.level = 1;
637 paragraph.lists.clear();
638 paragraph.tags.clear();
639 paragraph.style = None;
640 paragraph.collapsed = false;
641 paragraph.content = onestore::page::ParagraphContent::Text(text.clone());
642 let outline = onestore::page::Outline {
643 id: new_id().unwrap(),
644 title: false,
645 min_width: None,
646 layout: onestore::document::Layout {
647 x: Some(300.0),
648 y: Some(500.0),
649 ..Default::default()
650 },
651 indents: Vec::new(),
652 paragraphs: vec![paragraph],
653 unsupported: Vec::new(),
654 };
655 page.objects.insert(0, PageObject::Outline(outline));
656 let written = PreparedEdit::page(OUTLINES, space, &page, AUTHOR).unwrap();
657 let stored = page_in(written.as_bytes(), space);
658 let read_back = body(&stored)
659 .iter()
660 .flat_map(|outline| outline.paragraphs.iter())
661 .find_map(|p| p.text().filter(|t| t.id == text.id))
662 .unwrap();
663 assert_eq!(read_back.text.text(), "Plain 東京");
664 let format = read_back.text.format_at(0).unwrap();
665 assert!(format.font.is_some() && format.font_size.is_some());
666 assert_eq!(
667 PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR)
668 .unwrap()
669 .as_bytes(),
670 written.as_bytes()
671 );
672}
673
624674#[test]
625675fn an_unchanged_model_publishes_nothing() {
626676 let (space, page) = page_by_title(OUTLINES, "Resize outline");