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<'_> {...@@ -671,6 +671,7 @@ impl Lowering<'_> {
671 if stored.text.text() != text.text.text() {671 if stored.text.text() != text.text.text() {
672 return Err(invalid("Text edits did not converge on the model"));672 return Err(invalid("Text edits did not converge on the model"));
673 }673 }
674 let fresh = self.alias.contains_key(&text.id);
674 let mut boundaries = BTreeSet::new();675 let mut boundaries = BTreeSet::new();
675 for paragraph in [&stored.text, &text.text] {676 for paragraph in [&stored.text, &text.text] {
676 for span in paragraph.spans() {677 for span in paragraph.spans() {
...@@ -689,6 +690,7 @@ impl Lowering<'_> {...@@ -689,6 +690,7 @@ impl Lowering<'_> {
689 let attributes = attributes(690 let attributes = attributes(
690 format_in(&stored.text, start)?,691 format_in(&stored.text, start)?,
691 format_in(&text.text, start)?,692 format_in(&text.text, start)?,
693 fresh,
692 )?;694 )?;
693 match &mut pending {695 match &mut pending {
694 Some((range, previous)) if *previous == attributes && range.end == start => {696 Some((range, previous)) if *previous == attributes && range.end == start => {
...@@ -704,8 +706,11 @@ impl Lowering<'_> {...@@ -704,8 +706,11 @@ impl Lowering<'_> {
704 }706 }
705 edits.extend(pending);707 edits.extend(pending);
706 if text.text.text().is_empty() {708 if text.text.text().is_empty() {
707 let attributes =709 let attributes = attributes(
708 attributes(format_in(&stored.text, 0)?, format_in(&text.text, 0)?)?;710 format_in(&stored.text, 0)?,
711 format_in(&text.text, 0)?,
712 fresh,
713 )?;
709 if !attributes.is_empty() {714 if !attributes.is_empty() {
710 edits.push((0..0, attributes));715 edits.push((0..0, attributes));
711 }716 }
...@@ -900,17 +905,19 @@ fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> {...@@ -900,17 +905,19 @@ fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> {
900}905}
901906
902/// Explicit attributes turning `current` into `target`; unsupported differences are errors.907/// 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.
903/// Language tags are retained as stored because the model has no way to author them.910/// 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> {
905 let mut out = Vec::new();912 let mut out = Vec::new();
913 let inherited = || invalid("Inherited character formatting cannot be restored");
906 macro_rules! boolean {914 macro_rules! boolean {
907 ($field:ident, $variant:ident) => {915 ($field:ident, $variant:ident) => {
908 if current.$field != target.$field {916 if current.$field != target.$field {
909 match target.$field {917 match target.$field {
910 Some(value) => out.push(TextAttribute::$variant(value)),918 Some(value) => out.push(TextAttribute::$variant(value)),
911 None => {919 None if fresh => {}
912 return Err(invalid("Inherited character formatting cannot be restored"));920 None => return Err(inherited()),
913 }
914 }921 }
915 }922 }
916 };923 };
...@@ -924,13 +931,15 @@ fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, E...@@ -924,13 +931,15 @@ fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, E
924 if current.font != target.font {931 if current.font != target.font {
925 match &target.font {932 match &target.font {
926 Some(font) => out.push(TextAttribute::Font(font.clone())),933 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()),
928 }936 }
929 }937 }
930 if current.font_size != target.font_size {938 if current.font_size != target.font_size {
931 match target.font_size {939 match target.font_size {
932 Some(size) => out.push(TextAttribute::FontSize(size)),940 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()),
934 }943 }
935 }944 }
936 let color =945 let color =
...@@ -938,13 +947,15 @@ fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, E...@@ -938,13 +947,15 @@ fn attributes(current: &Format, target: &Format) -> Result<Vec<TextAttribute>, E
938 if current.color != target.color {947 if current.color != target.color {
939 match target.color {948 match target.color {
940 Some(value) => out.push(TextAttribute::Color(color(value))),949 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()),
942 }952 }
943 }953 }
944 if current.highlight != target.highlight {954 if current.highlight != target.highlight {
945 match target.highlight {955 match target.highlight {
946 Some(value) => out.push(TextAttribute::Highlight(color(value))),956 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()),
948 }959 }
949 }960 }
950 // An absent value and its stored default are the same formatting.961 // 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() {...@@ -621,6 +621,56 @@ fn unsupported_model_edits_are_rejected_before_writing() {
621 assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err());621 assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err());
622}622}
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
624#[test]674#[test]
625fn an_unchanged_model_publishes_nothing() {675fn an_unchanged_model_publishes_nothing() {
626 let (space, page) = page_by_title(OUTLINES, "Resize outline");676 let (space, page) = page_by_title(OUTLINES, "Resize outline");