authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 12:04:33-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 12:23:48-07:00
logc514498e33b81d02e7ad056ab3de8df11182d1fe
treea984685dc4cdca1e4c2bbf610d35ce319cbe13c7
parent91a2bf8cea7dd59ae56add4cee8bb968e653a439
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

6 files changed, 144 insertions(+), 5 deletions(-)

crates/onestore/src/op/apply.rs+3-1
...@@ -1175,7 +1175,9 @@ impl<'a> Writer<'_, 'a> {...@@ -1175,7 +1175,9 @@ impl<'a> Writer<'_, 'a> {
1175 }1175 }
1176 }1176 }
1177 if first.parent.is_some() {1177 if first.parent.is_some() {
1178 return Err(OpError::Unsupported("An outline's first paragraph has no parent").into());1178 return Err(
1179 OpError::Unsupported("An outline's first paragraph has no parent").into(),
1180 );
1179 }1181 }
1180 self.paragraph(outline.id, None, first, Some((outline.id, x, y)))?;1182 self.paragraph(outline.id, None, first, Some((outline.id, x, y)))?;
1181 // Later paragraphs follow the first, their levels as the outline gives them; a1183 // Later paragraphs follow the first, their levels as the outline gives them; a
crates/onestore/src/op/content.rs+5-1
...@@ -694,7 +694,11 @@ pub(crate) fn equation_changes(...@@ -694,7 +694,11 @@ pub(crate) fn equation_changes(
694 let all_math = text.spans().iter().all(|s| s.format.math == Some(true));694 let all_math = text.spans().iter().all(|s| s.format.math == Some(true));
695 for span in text.spans() {695 for span in text.spans() {
696 ends.extend(text.utf16_offset(span.end)?.to_le_bytes());696 ends.extend(text.utf16_offset(span.end)?.to_le_bytes());
697 styles.push(style_values(&span.format));697 // A run style states its language (MS-ONE 2.2.43), so the text object's never shows
698 // through; an unset one is the writer's insertion language.
699 let mut format = span.format.clone();
700 format.language.get_or_insert(0x409);
701 styles.push(style_values(&format));
698 sets.push(match &span.format.math_object {702 sets.push(match &span.format.math_object {
699 Some(object) => {703 Some(object) => {
700 let mut set = vec![(0x1400344f, object.kind.to_le_bytes().to_vec())];704 let mut set = vec![(0x1400344f, object.kind.to_le_bytes().to_vec())];
crates/onestore/src/op/model.rs+3-1
...@@ -676,9 +676,11 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {...@@ -676,9 +676,11 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
676 let object = paragraph.text_mut().unwrap();676 let object = paragraph.text_mut().unwrap();
677 let previous = object.text.spans()[0].format.clone();677 let previous = object.text.spans()[0].format.clone();
678 let math = Paragraph::from_runs(math.spans().iter().scan(0, |start, span| {678 let math = Paragraph::from_runs(math.spans().iter().scan(0, |start, span| {
679 let mut format = span.format.clone();
680 format.language.get_or_insert(0x409);
679 let run = (681 let run = (
680 math.text()[*start..span.end].to_owned(),682 math.text()[*start..span.end].to_owned(),
681 span.format.inherit(&inherited),683 format.inherit(&inherited),
682 );684 );
683 *start = span.end;685 *start = span.end;
684 Some(run)686 Some(run)
crates/onestore/src/op/tests.rs+127-1
...@@ -1561,7 +1561,7 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {...@@ -1561,7 +1561,7 @@ fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
1561 space_before: Some(4.5),1561 space_before: Some(4.5),
1562 space_after: None,1562 space_after: None,
1563 line_spacing: None,1563 line_spacing: None,
1564 language: None,1564 language: rng.coin().then_some(0x40c),
1565 },1565 },
1566 10 => {1566 10 => {
1567 let right = texts1567 let right = texts
...@@ -2975,3 +2975,129 @@ fn an_unset_language_is_not_paragraph_formatting() {...@@ -2975,3 +2975,129 @@ fn an_unset_language_is_not_paragraph_formatting() {
2975 Some(0x40c)2975 Some(0x40c)
2976 );2976 );
2977}2977}
2978
2979/// A paragraph given a language, then an equation whose runs name none: the model predicts
2980/// what the section stores.
2981#[test]
2982fn runs_written_after_a_paragraph_language_read_as_predicted() {
2983 let (_, source) = SOURCES[1];
2984 let space = pages(source)[0];
2985 let arena = Arena::default();
2986 let mut section = Section::open(&arena, source.to_vec()).unwrap();
2987 let before = section.page(space).unwrap();
2988 let paragraph = model::lists(&before)
2989 .into_iter()
2990 .flat_map(|(_, _, list)| list.iter())
2991 .find(|paragraph| {
2992 paragraph
2993 .text()
2994 .is_some_and(|text| !text.text.text().is_empty())
2995 })
2996 .unwrap()
2997 .clone();
2998 let text = paragraph.text().unwrap().id;
2999 let math = Format {
3000 math: Some(true),
3001 ..Default::default()
3002 };
3003 let ops = [
3004 PageOp::Paragraph {
3005 paragraph: paragraph.id,
3006 alignment: None,
3007 rtl: None,
3008 space_before: None,
3009 space_after: None,
3010 line_spacing: None,
3011 language: Some(0x40c),
3012 },
3013 PageOp::Equation {
3014 text,
3015 math: Paragraph::new("x".into(), math),
3016 },
3017 ];
3018 let mut predicted = before;
3019 for op in ops {
3020 model::apply(&mut predicted, &op).unwrap();
3021 section
3022 .apply(
3023 "Author",
3024 &Edit {
3025 at: AT,
3026 ops: vec![Op::Page { space, op }],
3027 },
3028 )
3029 .unwrap();
3030 let stored = section.page(space).unwrap();
3031 assert_eq!(
3032 normalize(&predicted),
3033 normalize(&stored),
3034 "{}",
3035 first_difference(&normalize(&stored), &normalize(&predicted))
3036 );
3037 }
3038}
3039
3040/// Undoing a deletion adds the object back whole: an outline whose first paragraph OneNote
3041/// tabbed into a group, and a TIFF picture OneNote imported, store as they were.
3042#[test]
3043fn deleted_objects_added_back_store_as_they_were() {
3044 let tree = SOURCES[2].1;
3045 let features = SOURCES[3].1;
3046 for (source, title) in [
3047 (tree, "Outdent first group"),
3048 (tree, "Delete unindented sibling after group"),
3049 (features, "Image tiff"),
3050 ] {
3051 let space = *pages(source)
3052 .iter()
3053 .find(|space| read(source, **space).title == title)
3054 .unwrap();
3055 let arena = Arena::default();
3056 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3057 let before = section.page(space).unwrap();
3058 let shape = |section: &mut Section, id: ExGuid| {
3059 let view = &section.active(space).unwrap().view;
3060 let mut shape = Vec::new();
3061 let mut pending = vec![(id, 0)];
3062 while let Some((id, depth)) = pending.pop() {
3063 let node = &view.nodes[&id];
3064 shape.push((
3065 depth,
3066 node.child_level,
3067 matches!(node.kind, Kind::OutlineGroup),
3068 ));
3069 pending.extend(node.children.iter().rev().map(|child| (*child, depth + 1)));
3070 }
3071 shape
3072 };
3073 let at = before
3074 .objects
3075 .iter()
3076 .position(|object| matches!(object, PageObject::Outline(_) | PageObject::Image(_)))
3077 .unwrap();
3078 let object = before.objects[at].id();
3079 let stored = shape(&mut section, object);
3080 let edit = |ops| Edit { at: AT, ops };
3081 section
3082 .apply(
3083 "Author",
3084 &edit(vec![Op::Page {
3085 space,
3086 op: PageOp::Delete { object },
3087 }]),
3088 )
3089 .unwrap();
3090 let gone = section.page(space).unwrap();
3091 let ops = lower_page(&gone, &before)
3092 .unwrap()
3093 .into_iter()
3094 .map(|op| Op::Page { space, op })
3095 .collect();
3096 section
3097 .apply("Author", &edit(ops))
3098 .unwrap_or_else(|error| panic!("{title}: {error:?}"));
3099 let after = section.page(space).unwrap();
3100 assert_eq!(normalize(&after), normalize(&before), "{title}");
3101 assert_eq!(shape(&mut section, object), stored, "{title}");
3102 }
3103}
crates/snowbound/src/main.rs+5
...@@ -2982,6 +2982,11 @@ impl State {...@@ -2982,6 +2982,11 @@ impl State {
2982 fn draw(&mut self) -> Result<(), Box<dyn Error>> {2982 fn draw(&mut self) -> Result<(), Box<dyn Error>> {
2983 let start = Instant::now();2983 let start = Instant::now();
2984 if let Some(path) = self.snapshot.take() {2984 if let Some(path) = self.snapshot.take() {
2985 // A hidden window never paints, so nothing polls the pictures between snapshots.
2986 let paper = self.paper();
2987 if let Some((scene, _)) = &mut self.view.scene {
2988 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
2989 }
2985 self.snapshot(&path)?;2990 self.snapshot(&path)?;
2986 }2991 }
2987 if let Some((dir, opened)) = &self.frames {2992 if let Some((dir, opened)) = &self.frames {
tools/canvas/README.md+1-1
...@@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed,...@@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed,
5454
55The `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.55The `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.
5656
57A 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"}`.57A 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"}`.
5858
59```sh59```sh
60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one