1use crate::{
2 Error, ExGuid,
3 active::{ActivePage, Changes},
4 create::current_timestamps,
5 document::Kind,
6 edit::update_title,
7 write::PropertyObject,
8};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12/// A layout or saved expansion change that preserves content and object identities.
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub enum OutlineEdit {
16 /// Moves an ordinary page outline or a file on the page to coordinates measured in
17 /// points, or offsets a page's ink drawing from its strokes' coordinates by that much,
18 /// as OneNote 2010 moves one.
19 /// Refreshes automatic page titles when the leading outline changes.
20 Position { x: f32, y: f32 },
21 /// Changes an ordinary outline's maximum width in points, at least 36.
22 /// `user_set` distinguishes an explicit width from an automatic layout hint.
23 /// Height remains content-derived; any previous reserved wrapping width is cleared.
24 Width { points: f32, user_set: bool },
25 /// Sets a paragraph's saved expansion default; native cache-local UI state can override it.
26 Collapsed(bool),
27}
28
29impl OutlineEdit {
30 pub(crate) fn changes(self, active: &ActivePage<'_>, object: ExGuid) -> Result<Changes, Error> {
31 let invalid = |message| Error { offset: 0, message };
32 let values = match self {
33 Self::Position { x, y } => {
34 if !x.is_finite() || !y.is_finite() {
35 return Err(invalid("Outline coordinates must be finite"));
36 }
37 vec![
38 (0x14001c14, (x / 36.0).to_le_bytes().to_vec()),
39 (0x14001c15, (y / 36.0).to_le_bytes().to_vec()),
40 ]
41 }
42 Self::Width { points, user_set } => {
43 if !points.is_finite() || points < 36.0 {
44 return Err(invalid(
45 "Outline width must be finite and at least 36 points",
46 ));
47 }
48 vec![
49 (0x14001c1b, (points / 36.0).to_le_bytes().to_vec()),
50 (0x08001cbd | (u32::from(user_set) << 31), Vec::new()),
51 ]
52 }
53 Self::Collapsed(value) => vec![(0x0c001c11, vec![u8::from(value)])],
54 };
55 let pages = &active.pages;
56 let [page] = pages.as_slice() else {
57 return Err(invalid("Outline editing requires a single active page"));
58 };
59 let view = &active.view;
60 let parents = active.editable_parents(object)?;
61 let node = &view.nodes[&object];
62 match self {
63 Self::Collapsed(_) => {
64 if !matches!(node.kind, Kind::Paragraph { .. }) {
65 return Err(invalid("Select a paragraph for its saved expansion state"));
66 }
67 }
68 Self::Position { .. } => {
69 if !matches!(
70 node.kind,
71 Kind::Outline { .. } | Kind::Ink { .. } | Kind::Attachment { .. }
72 ) || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
73 || !view.nodes[page].children.contains(&object)
74 {
75 return Err(invalid(
76 "Select an ordinary outline, file or ink drawing directly on the page",
77 ));
78 }
79 }
80 Self::Width { .. } => {
81 if !matches!(node.kind, Kind::Outline { .. })
82 || parents.get(&object).map(Vec::as_slice) != Some(pages.as_slice())
83 || !view.nodes[page].children.contains(&object)
84 {
85 return Err(invalid("Select an ordinary outline directly on the page"));
86 }
87 }
88 }
89 let mut ancestors = BTreeSet::new();
90 let mut pending = vec![object];
91 while let Some(id) = pending.pop() {
92 if !ancestors.insert(id) {
93 return Err(invalid("Outline ancestry contains a cycle"));
94 }
95 if id != *page && parents.get(&id).is_none_or(|parents| parents.len() != 1) {
96 return Err(invalid("Outline content must have one active parent"));
97 }
98 if matches!(view.nodes[&id].kind, Kind::Title)
99 || view.nodes[&id].extra[0]
100 .iter()
101 .any(|field| matches!(field.id, 0x88001cb4 | 0x88001cf9 | 0x88001cb2))
102 {
103 return Err(invalid(
104 "Title or protected outline content cannot be changed here",
105 ));
106 }
107 pending.extend(parents.get(&id).into_iter().flatten().copied());
108 }
109 let modified = current_timestamps()?.0.to_le_bytes();
110 let raw = &active.live.revision;
111 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
112 target.set(
113 &values
114 .iter()
115 .map(|(id, data)| (*id, data.as_slice()))
116 .collect::<Vec<_>>(),
117 )?;
118 if matches!(self, Self::Width { .. }) {
119 target.remove(&[0x14001cdb])?;
120 }
121 if raw.objects[&object].data == crate::ObjectData::Properties(&target.bytes) {
122 return Ok(BTreeMap::new());
123 }
124 target.set(&[(0x14001d7a, &modified)])?;
125 let mut changed = BTreeMap::from([(object, target)]);
126 for id in ancestors {
127 if id != object {
128 let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?;
129 ancestor.set(&[(0x14001d7a, &modified)])?;
130 changed.insert(id, ancestor);
131 }
132 }
133 if matches!(self, Self::Position { .. }) {
134 // OneNote gives a page its margin origin when an outline on it first moves.
135 let page = changed.get_mut(page).unwrap();
136 if !crate::PropertySets::parse(&page.bytes)?.sets[0]
137 .iter()
138 .any(|field| field.id == 0x14001d0f)
139 {
140 page.set(&[
141 (0x14001d0f, &1.0_f32.to_le_bytes()),
142 (0x14001d10, &0.4_f32.to_le_bytes()),
143 ])?;
144 }
145 update_title(active, view.clone(), &mut changed)?;
146 }
147 Ok(changed)
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::{
155 RevisionIndex, Store,
156 document::Document,
157 op::{PageOp, tests::edited},
158 write::write_revision,
159 };
160
161 #[test]
162 fn protection_on_the_target_or_ancestor_prevents_layout_edits() {
163 let source = crate::create_section("layout.one", "Text", "Author").unwrap();
164 let store = Store::parse(&source).unwrap();
165 let index = RevisionIndex::parse(&store).unwrap();
166 let document = Document::parse(&index).unwrap();
167 let (sid, page) = document.pages().unwrap()[0];
168 let view = document.active(sid).unwrap();
169 let outline = *view.nodes[&page]
170 .children
171 .iter()
172 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
173 .unwrap();
174 let paragraph = view.nodes[&outline].children[0];
175 for target in [outline, page] {
176 for property in [0x08001cde, 0x08001cb4, 0x08001cf9, 0x08001cb2] {
177 for enabled in [false, true] {
178 let bytes = write_revision(&source, sid, |raw| {
179 let mut object = PropertyObject::from_object(&raw.objects[&target])?;
180 object.set(&[(property | (u32::from(enabled) << 31), &[])])?;
181 Ok(BTreeMap::from([(target, object)]))
182 })
183 .unwrap();
184 for (object, edit) in [
185 (outline, OutlineEdit::Position { x: 72.0, y: 72.0 }),
186 (
187 outline,
188 OutlineEdit::Width {
189 points: 144.0,
190 user_set: true,
191 },
192 ),
193 (paragraph, OutlineEdit::Collapsed(true)),
194 ] {
195 assert_eq!(
196 edited(&bytes, sid, vec![PageOp::Outline { object, edit }]).is_err(),
197 enabled,
198 "{target} {property:x} {edit:?}"
199 );
200 }
201 }
202 }
203 }
204 let duplicated = write_revision(&source, sid, |raw| {
205 let mut object = PropertyObject::from_object(&raw.objects[&outline])?;
206 let reference = object.reference(paragraph)?;
207 object.set(&[(0x24001c20, &reference.repeat(2))])?;
208 Ok(BTreeMap::from([(outline, object)]))
209 })
210 .unwrap();
211 let collapse = PageOp::Outline {
212 object: paragraph,
213 edit: OutlineEdit::Collapsed(true),
214 };
215 assert!(edited(&duplicated, sid, vec![collapse]).is_err());
216 }
217}