| author | |
| committer | |
| log | c514498e33b81d02e7ad056ab3de8df11182d1fe |
| tree | a984685dc4cdca1e4c2bbf610d35ce319cbe13c7 |
| parent | 91a2bf8cea7dd59ae56add4cee8bb968e653a439 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.56 files changed, 144 insertions(+), 5 deletions(-)
crates/onestore/src/op/apply.rs+3-1| ... | ... | @@ -1175,7 +1175,9 @@ impl<'a> Writer<'_, 'a> { |
| 1175 | 1175 | } |
| 1176 | 1176 | } |
| 1177 | 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 | 1182 | self.paragraph(outline.id, None, first, Some((outline.id, x, y)))?; |
| 1181 | 1183 | // 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 | 694 | let all_math = text.spans().iter().all(|s| s.format.math == Some(true)); |
| 695 | 695 | for span in text.spans() { |
| 696 | 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 | 702 | sets.push(match &span.format.math_object { |
| 699 | 703 | Some(object) => { |
| 700 | 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 | 676 | let object = paragraph.text_mut().unwrap(); |
| 677 | 677 | let previous = object.text.spans()[0].format.clone(); |
| 678 | 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 | 681 | let run = ( |
| 680 | 682 | math.text()[*start..span.end].to_owned(), |
| 681 | span.format.inherit(&inherited), | |
| 683 | format.inherit(&inherited), | |
| 682 | 684 | ); |
| 683 | 685 | *start = span.end; |
| 684 | 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 | 1561 | space_before: Some(4.5), |
| 1562 | 1562 | space_after: None, |
| 1563 | 1563 | line_spacing: None, |
| 1564 | language: None, | |
| 1564 | language: rng.coin().then_some(0x40c), | |
| 1565 | 1565 | }, |
| 1566 | 1566 | 10 => { |
| 1567 | 1567 | let right = texts |
| ... | ... | @@ -2975,3 +2975,129 @@ fn an_unset_language_is_not_paragraph_formatting() { |
| 2975 | 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] | |
| 2982 | fn 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] | |
| 3043 | fn 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 | 2982 | fn draw(&mut self) -> Result<(), Box<dyn Error>> { |
| 2983 | 2983 | let start = Instant::now(); |
| 2984 | 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 | 2990 | self.snapshot(&path)?; |
| 2986 | 2991 | } |
| 2987 | 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 | 54 | |
| 55 | 55 | 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. |
| 56 | 56 | |
| 57 | 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"}`. | |
| 57 | 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"}`. | |
| 58 | 58 | |
| 59 | 59 | ```sh |
| 60 | 60 | cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one |