From c514498e33b81d02e7ad056ab3de8df11182d1fe Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 29 Sep 2026 12:04:33 -0700 Subject: [PATCH] fix: undo restores deeper first paragraphs and TIFF pictures; replay snapshots wait for images - Adding back a deleted outline whose first paragraph is deeper than level 1 regroups it the way the Level op does, as OneNote stores it. - TIFF pictures, which OneNote keeps as TIFF, can be added back. - Equation runs always carry a language, as the spec requires a run style to, and the op model predicts it. - Replay snapshots settle pending pictures before capturing. Assisted-by: claude-opus-5.5 --- crates/onestore/src/op/apply.rs | 4 +- crates/onestore/src/op/content.rs | 6 +- crates/onestore/src/op/model.rs | 4 +- crates/onestore/src/op/tests.rs | 128 +++++++++++++++++++++++++++++- crates/snowbound/src/main.rs | 5 ++ tools/canvas/README.md | 2 +- 6 files changed, 144 insertions(+), 5 deletions(-) diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index 791a4f2fd124c33336171c46af71f095f4a3e920..3fcab72678b98e557e839720dbe6786b0d6a4b57 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -1175,7 +1175,9 @@ impl<'a> Writer<'_, 'a> { } } if first.parent.is_some() { - return Err(OpError::Unsupported("An outline's first paragraph has no parent").into()); + return Err( + OpError::Unsupported("An outline's first paragraph has no parent").into(), + ); } self.paragraph(outline.id, None, first, Some((outline.id, x, y)))?; // Later paragraphs follow the first, their levels as the outline gives them; a diff --git a/crates/onestore/src/op/content.rs b/crates/onestore/src/op/content.rs index b7b9ea71a6ca6750c9fae28f11f86acbfb7e7724..cd897b9107d76050272e315f5556969367e073ba 100644 --- a/crates/onestore/src/op/content.rs +++ b/crates/onestore/src/op/content.rs @@ -694,7 +694,11 @@ pub(crate) fn equation_changes( let all_math = text.spans().iter().all(|s| s.format.math == Some(true)); for span in text.spans() { ends.extend(text.utf16_offset(span.end)?.to_le_bytes()); - styles.push(style_values(&span.format)); + // A run style states its language (MS-ONE 2.2.43), so the text object's never shows + // through; an unset one is the writer's insertion language. + let mut format = span.format.clone(); + format.language.get_or_insert(0x409); + styles.push(style_values(&format)); sets.push(match &span.format.math_object { Some(object) => { let mut set = vec![(0x1400344f, object.kind.to_le_bytes().to_vec())]; diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs index 7bc0907bfa16490b3ff3be3bcea08965f4bec8fa..c03231f41104c61b5297c58b2de9dfdcf00cfab1 100644 --- a/crates/onestore/src/op/model.rs +++ b/crates/onestore/src/op/model.rs @@ -676,9 +676,11 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { let object = paragraph.text_mut().unwrap(); let previous = object.text.spans()[0].format.clone(); let math = Paragraph::from_runs(math.spans().iter().scan(0, |start, span| { + let mut format = span.format.clone(); + format.language.get_or_insert(0x409); let run = ( math.text()[*start..span.end].to_owned(), - span.format.inherit(&inherited), + format.inherit(&inherited), ); *start = span.end; Some(run) diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index 3e20993b312677f5c6be13b739f43c7e44edbb4c..d88b3eb776b3e5cf1616609583b735b9293bfe98 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -1561,7 +1561,7 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option { space_before: Some(4.5), space_after: None, line_spacing: None, - language: None, + language: rng.coin().then_some(0x40c), }, 10 => { let right = texts @@ -2975,3 +2975,129 @@ fn an_unset_language_is_not_paragraph_formatting() { Some(0x40c) ); } + +/// A paragraph given a language, then an equation whose runs name none: the model predicts +/// what the section stores. +#[test] +fn runs_written_after_a_paragraph_language_read_as_predicted() { + let (_, source) = SOURCES[1]; + let space = pages(source)[0]; + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let before = section.page(space).unwrap(); + let paragraph = model::lists(&before) + .into_iter() + .flat_map(|(_, _, list)| list.iter()) + .find(|paragraph| { + paragraph + .text() + .is_some_and(|text| !text.text.text().is_empty()) + }) + .unwrap() + .clone(); + let text = paragraph.text().unwrap().id; + let math = Format { + math: Some(true), + ..Default::default() + }; + let ops = [ + PageOp::Paragraph { + paragraph: paragraph.id, + alignment: None, + rtl: None, + space_before: None, + space_after: None, + line_spacing: None, + language: Some(0x40c), + }, + PageOp::Equation { + text, + math: Paragraph::new("x".into(), math), + }, + ]; + let mut predicted = before; + for op in ops { + model::apply(&mut predicted, &op).unwrap(); + section + .apply( + "Author", + &Edit { + at: AT, + ops: vec![Op::Page { space, op }], + }, + ) + .unwrap(); + let stored = section.page(space).unwrap(); + assert_eq!( + normalize(&predicted), + normalize(&stored), + "{}", + first_difference(&normalize(&stored), &normalize(&predicted)) + ); + } +} + +/// Undoing a deletion adds the object back whole: an outline whose first paragraph OneNote +/// tabbed into a group, and a TIFF picture OneNote imported, store as they were. +#[test] +fn deleted_objects_added_back_store_as_they_were() { + let tree = SOURCES[2].1; + let features = SOURCES[3].1; + for (source, title) in [ + (tree, "Outdent first group"), + (tree, "Delete unindented sibling after group"), + (features, "Image tiff"), + ] { + let space = *pages(source) + .iter() + .find(|space| read(source, **space).title == title) + .unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let before = section.page(space).unwrap(); + let shape = |section: &mut Section, id: ExGuid| { + let view = §ion.active(space).unwrap().view; + let mut shape = Vec::new(); + let mut pending = vec![(id, 0)]; + while let Some((id, depth)) = pending.pop() { + let node = &view.nodes[&id]; + shape.push(( + depth, + node.child_level, + matches!(node.kind, Kind::OutlineGroup), + )); + pending.extend(node.children.iter().rev().map(|child| (*child, depth + 1))); + } + shape + }; + let at = before + .objects + .iter() + .position(|object| matches!(object, PageObject::Outline(_) | PageObject::Image(_))) + .unwrap(); + let object = before.objects[at].id(); + let stored = shape(&mut section, object); + let edit = |ops| Edit { at: AT, ops }; + section + .apply( + "Author", + &edit(vec![Op::Page { + space, + op: PageOp::Delete { object }, + }]), + ) + .unwrap(); + let gone = section.page(space).unwrap(); + let ops = lower_page(&gone, &before) + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section + .apply("Author", &edit(ops)) + .unwrap_or_else(|error| panic!("{title}: {error:?}")); + let after = section.page(space).unwrap(); + assert_eq!(normalize(&after), normalize(&before), "{title}"); + assert_eq!(shape(&mut section, object), stored, "{title}"); + } +} diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 698324550e8f9a554c2220a561f170fe54e68797..21289acf0965534b1da6def0f7372e5f022a7b95 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -2982,6 +2982,11 @@ impl State { fn draw(&mut self) -> Result<(), Box> { let start = Instant::now(); if let Some(path) = self.snapshot.take() { + // A hidden window never paints, so nothing polls the pictures between snapshots. + let paper = self.paper(); + if let Some((scene, _)) = &mut self.view.scene { + scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper); + } self.snapshot(&path)?; } if let Some((dir, opened)) = &self.frames { diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 0b9fca2e836594f355c23af503d8173b4d4e8bce..8392ec574fbfeee3f7f53497a07bf2295d0dbd04 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed, The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. -A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `appearance light|dark` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. +A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `appearance light|dark` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. ```sh cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one -- 2.54.0