| author | |
| committer | |
| log | e00506af62d3a184a53b6dec1b40ac6bd6372c3c |
| tree | 9b01549bbe63d307a541267c5094aa507f929c1a |
| parent | ad10b477304448e0310a90215fdfab07b4dd337f |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Move the canvas page model into onestore::page: Page, Title, Outline,
PageParagraph, Table, TextObject, Image and Unsupported keep stored
identities, resolve inherited formatting, and retain everything else on the
page as Unsupported. page::text::Paragraph coalesces rich text into spans with
UTF-8/UTF-16 offset mapping, hidden-field projection, slicing and invertible
edits. Page::from_space selects the single active page of an object space.
The page writer module is renamed pages.rs; its public types are unchanged.
Validation: 18 model unit tests ported from the canvas branch; every page in
the 396-file public corpus builds a model with distinct identities; Clippy,
fmt and doctests. Merge instructions for the UI branch are in
evidence/M1-MERGE.md.
Assisted-by: claude-fable-5.19 files changed, 2828 insertions(+), 888 deletions(-)
crates/onestore/README.md+2| ... | ... | @@ -43,6 +43,8 @@ harness also accepts `--client-profile release`. |
| 43 | 43 | | `PropertySets`, `Object::references` | Decode properties and ID streams while retaining raw values | |
| 44 | 44 | | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | |
| 45 | 45 | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | |
| 46 | | `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups | | |
| 47 | | `page::Page`, `page::Paragraph` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images) with stored identities; content outside the model is retained as `Unsupported` | | |
| 46 | 48 | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | |
| 47 | 49 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 48 | 50 | | `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | |
crates/onestore/src/commit.rs+2-2| ... | ... | @@ -188,7 +188,7 @@ impl<'a> PreparedEdit<'a> { |
| 188 | 188 | pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result<Self, crate::Error> { |
| 189 | 189 | Ok(Self { |
| 190 | 190 | source, |
| 191 | written: crate::page::edit_pages(source, edits, &[])?, | |
| 191 | written: crate::pages::edit_pages(source, edits, &[])?, | |
| 192 | 192 | }) |
| 193 | 193 | } |
| 194 | 194 | |
| ... | ... | @@ -201,7 +201,7 @@ impl<'a> PreparedEdit<'a> { |
| 201 | 201 | ) -> Result<Self, crate::Error> { |
| 202 | 202 | Ok(Self { |
| 203 | 203 | source, |
| 204 | written: crate::page::edit_pages(source, &[], pages)?, | |
| 204 | written: crate::pages::edit_pages(source, &[], pages)?, | |
| 205 | 205 | }) |
| 206 | 206 | } |
| 207 | 207 |
crates/onestore/src/lib.rs+3-2| ... | ... | @@ -12,7 +12,8 @@ mod formatting; |
| 12 | 12 | mod insertion; |
| 13 | 13 | mod objects; |
| 14 | 14 | mod outline; |
| 15 | mod page; | |
| 15 | pub mod page; | |
| 16 | mod pages; | |
| 16 | 17 | mod paragraph; |
| 17 | 18 | mod properties; |
| 18 | 19 | #[cfg(feature = "protected")] |
| ... | ... | @@ -36,7 +37,7 @@ pub use formatting::TextAttribute; |
| 36 | 37 | pub use insertion::Insertion; |
| 37 | 38 | pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; |
| 38 | 39 | pub use outline::OutlineEdit; |
| 39 | pub use page::{PageCreation, PageEdit, PagePosition}; | |
| 40 | pub use pages::{PageCreation, PageEdit, PagePosition}; | |
| 40 | 41 | pub use paragraph::{ParagraphJoin, ParagraphSplit}; |
| 41 | 42 | pub use properties::{IdStream, Property, PropertySets, Value}; |
| 42 | 43 | pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; |
crates/onestore/src/page.rs deleted-883| ... | ... | @@ -1,883 +0,0 @@ |
| 1 | use crate::{ | |
| 2 | Error, ExGuid, | |
| 3 | create::{current_timestamps, properties, string}, | |
| 4 | document::{Document, FieldValue, Kind}, | |
| 5 | write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions}, | |
| 6 | }; | |
| 7 | use serde::{Deserialize, Serialize}; | |
| 8 | use std::{ | |
| 9 | collections::{BTreeMap, BTreeSet}, | |
| 10 | sync::Arc, | |
| 11 | }; | |
| 12 | ||
| 13 | fn invalid(message: &'static str) -> Error { | |
| 14 | Error { offset: 0, message } | |
| 15 | } | |
| 16 | ||
| 17 | fn metadata_id(space: ExGuid) -> ExGuid { | |
| 18 | let mut id = ExGuid { | |
| 19 | guid: space.guid, | |
| 20 | n: 1, | |
| 21 | }; | |
| 22 | for (byte, salt) in id.guid.iter_mut().zip([ | |
| 23 | 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24, 0x35, 0xe8, | |
| 24 | ]) { | |
| 25 | *byte ^= salt; | |
| 26 | } | |
| 27 | id | |
| 28 | } | |
| 29 | ||
| 30 | fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> { | |
| 31 | metadata.extra[0] | |
| 32 | .iter() | |
| 33 | .find_map(|field| match field.value { | |
| 34 | FieldValue::Bytes(bytes) if field.id == 0x1c001c30 => bytes.try_into().ok(), | |
| 35 | _ => None, | |
| 36 | }) | |
| 37 | .ok_or_else(|| invalid("Page metadata needs its page identifier")) | |
| 38 | } | |
| 39 | ||
| 40 | fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> { | |
| 41 | let mut references = Vec::new(); | |
| 42 | for id in ids { | |
| 43 | references.extend_from_slice(&object.reference(*id)?); | |
| 44 | } | |
| 45 | let source = PropertyObject { | |
| 46 | jcid: object.jcid, | |
| 47 | bytes: properties(&[(property, references)])?, | |
| 48 | global_ids: Arc::clone(&object.global_ids), | |
| 49 | }; | |
| 50 | object.copy_property(&source, property) | |
| 51 | } | |
| 52 | ||
| 53 | /// Whether an edit preserves position or moves before a page (None appends). | |
| 54 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 55 | #[serde(deny_unknown_fields)] | |
| 56 | pub enum PagePosition { | |
| 57 | Keep, | |
| 58 | Before(Option<ExGuid>), | |
| 59 | } | |
| 60 | ||
| 61 | /// A page's explicit position and indentation, with a stable identity for a new series. | |
| 62 | /// Moving a page does not implicitly select its following subpages. | |
| 63 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 64 | #[serde(deny_unknown_fields)] | |
| 65 | pub struct PageEdit { | |
| 66 | guid: [u8; 16], | |
| 67 | space: ExGuid, | |
| 68 | level: u32, | |
| 69 | position: PagePosition, | |
| 70 | } | |
| 71 | ||
| 72 | impl PageEdit { | |
| 73 | /// Changes one page's indentation without changing its position. | |
| 74 | pub fn set_level(space: ExGuid, level: u32) -> Result<Self, Error> { | |
| 75 | Self::new(space, level, PagePosition::Keep) | |
| 76 | } | |
| 77 | ||
| 78 | /// Moves before an existing page space, or appends when `before` is None. | |
| 79 | pub fn move_to(space: ExGuid, before: Option<ExGuid>, level: u32) -> Result<Self, Error> { | |
| 80 | Self::new(space, level, PagePosition::Before(before)) | |
| 81 | } | |
| 82 | ||
| 83 | pub fn space(&self) -> ExGuid { | |
| 84 | self.space | |
| 85 | } | |
| 86 | ||
| 87 | pub fn level(&self) -> u32 { | |
| 88 | self.level | |
| 89 | } | |
| 90 | ||
| 91 | pub fn position(&self) -> PagePosition { | |
| 92 | self.position | |
| 93 | } | |
| 94 | ||
| 95 | /// Revises placement while retaining the selected page and allocated series identity. | |
| 96 | pub fn reposition(&self, position: PagePosition, level: u32) -> Result<Self, Error> { | |
| 97 | let edit = Self { | |
| 98 | position, | |
| 99 | level, | |
| 100 | ..self.clone() | |
| 101 | }; | |
| 102 | edit.validate()?; | |
| 103 | Ok(edit) | |
| 104 | } | |
| 105 | ||
| 106 | fn new(space: ExGuid, level: u32, position: PagePosition) -> Result<Self, Error> { | |
| 107 | let edit = Self { | |
| 108 | guid: fresh_guid()?, | |
| 109 | space, | |
| 110 | level, | |
| 111 | position, | |
| 112 | }; | |
| 113 | edit.validate()?; | |
| 114 | Ok(edit) | |
| 115 | } | |
| 116 | ||
| 117 | fn validate(&self) -> Result<(), Error> { | |
| 118 | if self.guid == [0; 16] || self.space.guid == [0; 16] || !(1..=3).contains(&self.level) { | |
| 119 | return Err(invalid( | |
| 120 | "Choose an existing page space and indentation level 1 through 3", | |
| 121 | )); | |
| 122 | } | |
| 123 | if let PagePosition::Before(Some(before)) = self.position | |
| 124 | && (before.guid == [0; 16] || before == self.space) | |
| 125 | { | |
| 126 | return Err(invalid("Choose a different page as the movement anchor")); | |
| 127 | } | |
| 128 | Ok(()) | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | pub(crate) fn edit_pages( | |
| 133 | source: &[u8], | |
| 134 | edits: &[PageEdit], | |
| 135 | removals: &[ExGuid], | |
| 136 | ) -> Result<Vec<u8>, Error> { | |
| 137 | if !edits.is_empty() && !removals.is_empty() { | |
| 138 | return Err(invalid( | |
| 139 | "Choose page placement or page removal for one operation", | |
| 140 | )); | |
| 141 | } | |
| 142 | let removed: BTreeSet<_> = removals.iter().copied().collect(); | |
| 143 | if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) { | |
| 144 | return Err(invalid("Choose distinct existing page spaces for removal")); | |
| 145 | } | |
| 146 | let mut selected = BTreeMap::new(); | |
| 147 | let mut guids = BTreeSet::new(); | |
| 148 | for edit in edits { | |
| 149 | edit.validate()?; | |
| 150 | if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) { | |
| 151 | return Err(invalid( | |
| 152 | "Use distinct page and edit identities within a batch", | |
| 153 | )); | |
| 154 | } | |
| 155 | } | |
| 156 | write_revisions(source, |index| { | |
| 157 | let document = Document::parse(index)?; | |
| 158 | let pages = document.pages()?; | |
| 159 | let section = &document.spaces[&document.root]; | |
| 160 | let view = &section.revisions[&section.contexts[&ExGuid::default()]]; | |
| 161 | let section_id = view.roots[&1]; | |
| 162 | let section_node = &view.nodes[&section_id]; | |
| 163 | if !matches!(section_node.kind, Kind::Section { .. }) | |
| 164 | || section_node.extra[0] | |
| 165 | .iter() | |
| 166 | .any(|field| field.id == 0x88001cde) | |
| 167 | { | |
| 168 | return Err(invalid("Choose an editable section for page editing")); | |
| 169 | } | |
| 170 | let mut order = Vec::new(); | |
| 171 | let mut levels = BTreeMap::new(); | |
| 172 | let mut page_guids = BTreeMap::new(); | |
| 173 | for (sid, _) in &pages { | |
| 174 | let page = &document.spaces[sid]; | |
| 175 | let page = &page.revisions[&page.contexts[&ExGuid::default()]]; | |
| 176 | let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { | |
| 177 | return Err(invalid("Page metadata is unavailable")); | |
| 178 | }; | |
| 179 | let Kind::Metadata { level, .. } = metadata.kind else { | |
| 180 | return Err(invalid("Choose ordinary pages with page metadata")); | |
| 181 | }; | |
| 182 | let level = level.unwrap_or(1); | |
| 183 | if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { | |
| 184 | return Err(invalid( | |
| 185 | "Each page space must contain one page with a valid level", | |
| 186 | )); | |
| 187 | } | |
| 188 | if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() { | |
| 189 | return Err(invalid("Pages must have distinct metadata identifiers")); | |
| 190 | } | |
| 191 | order.push(*sid); | |
| 192 | } | |
| 193 | let original_order = order.clone(); | |
| 194 | let original_levels = levels.clone(); | |
| 195 | let mut series_by_head = BTreeMap::new(); | |
| 196 | let mut copies = BTreeMap::new(); | |
| 197 | let mut copy_ids = BTreeSet::new(); | |
| 198 | for oid in &section_node.children { | |
| 199 | let series = &view.nodes[oid]; | |
| 200 | let Some(head) = series.spaces.first() else { | |
| 201 | return Err(invalid("A page series must contain at least one page")); | |
| 202 | }; | |
| 203 | if levels.get(head) != Some(&1) | |
| 204 | || series.spaces[1..] | |
| 205 | .iter() | |
| 206 | .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) | |
| 207 | { | |
| 208 | return Err(invalid( | |
| 209 | "A page series must start with its only top-level page", | |
| 210 | )); | |
| 211 | } | |
| 212 | if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { | |
| 213 | series_by_head.insert(*retained, *oid); | |
| 214 | } | |
| 215 | if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { | |
| 216 | let FieldValue::Objects(ids) = &field.value else { | |
| 217 | return Err(invalid("Page metadata copies must be object references")); | |
| 218 | }; | |
| 219 | if ids.len() != series.spaces.len() { | |
| 220 | return Err(invalid("Page metadata copies must match their series")); | |
| 221 | } | |
| 222 | for id in ids { | |
| 223 | if !copy_ids.insert(*id) | |
| 224 | || !matches!( | |
| 225 | view.nodes.get(id).map(|n| &n.kind), | |
| 226 | Some(Kind::Metadata { .. }) | |
| 227 | ) | |
| 228 | { | |
| 229 | return Err(invalid("Each page needs its own ordinary metadata copy")); | |
| 230 | } | |
| 231 | let sid = page_guids | |
| 232 | .get(&metadata_guid(&view.nodes[id])?) | |
| 233 | .filter(|sid| series.spaces.contains(sid)) | |
| 234 | .ok_or_else(|| { | |
| 235 | invalid("Metadata copy must identify a page in its series") | |
| 236 | })?; | |
| 237 | if copies.insert(*sid, *id).is_some() { | |
| 238 | return Err(invalid("Each page needs its own ordinary metadata copy")); | |
| 239 | } | |
| 240 | } | |
| 241 | } | |
| 242 | } | |
| 243 | for sid in &removed { | |
| 244 | if !levels.contains_key(sid) { | |
| 245 | return Err(invalid("The selected page is no longer in the section")); | |
| 246 | } | |
| 247 | } | |
| 248 | order.retain(|sid| !removed.contains(sid)); | |
| 249 | if !removed.is_empty() | |
| 250 | && let Some(first) = order.first() | |
| 251 | { | |
| 252 | levels.insert(*first, 1); | |
| 253 | } | |
| 254 | for edit in edits { | |
| 255 | let Some(at) = order.iter().position(|sid| *sid == edit.space) else { | |
| 256 | return Err(invalid("The selected page is no longer in the section")); | |
| 257 | }; | |
| 258 | levels.insert(edit.space, edit.level); | |
| 259 | if let PagePosition::Before(before) = edit.position { | |
| 260 | order.remove(at); | |
| 261 | let position = match before { | |
| 262 | None => order.len(), | |
| 263 | Some(before) => { | |
| 264 | order.iter().position(|sid| *sid == before).ok_or_else(|| { | |
| 265 | invalid("The movement anchor is no longer in the section") | |
| 266 | })? | |
| 267 | } | |
| 268 | }; | |
| 269 | order.insert(position, edit.space); | |
| 270 | } | |
| 271 | } | |
| 272 | if order.first().is_some_and(|sid| levels[sid] != 1) { | |
| 273 | return Err(invalid( | |
| 274 | "The section's first page must have indentation level 1", | |
| 275 | )); | |
| 276 | } | |
| 277 | if order == original_order && levels == original_levels { | |
| 278 | return Ok(BTreeMap::new()); | |
| 279 | } | |
| 280 | let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; | |
| 281 | let mut groups: Vec<Vec<ExGuid>> = Vec::new(); | |
| 282 | for sid in order { | |
| 283 | if levels[&sid] == 1 { | |
| 284 | groups.push(Vec::new()); | |
| 285 | } | |
| 286 | groups.last_mut().unwrap().push(sid); | |
| 287 | } | |
| 288 | let mut changes = BTreeMap::new(); | |
| 289 | for sid in &removed { | |
| 290 | let page = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; | |
| 291 | let manifest_id = page.roots[&1]; | |
| 292 | let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?; | |
| 293 | if manifest.jcid != 0x60037 { | |
| 294 | return Err(invalid("Choose ordinary pages for removal")); | |
| 295 | } | |
| 296 | manifest.bytes = properties(&[])?; | |
| 297 | let metadata_id = page.roots[&2]; | |
| 298 | let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?; | |
| 299 | metadata.set(&[(0x88001de9, &[])])?; | |
| 300 | changes.insert( | |
| 301 | *sid, | |
| 302 | RevisionEdit::Update(BTreeMap::from([ | |
| 303 | (manifest_id, manifest), | |
| 304 | (metadata_id, metadata), | |
| 305 | ])), | |
| 306 | ); | |
| 307 | } | |
| 308 | let mut replacements = BTreeMap::new(); | |
| 309 | let mut children = Vec::new(); | |
| 310 | for spaces in groups { | |
| 311 | let head = spaces[0]; | |
| 312 | let old_id = series_by_head.get(&head).copied(); | |
| 313 | let (id, mut series) = if let Some(id) = old_id { | |
| 314 | (id, PropertyObject::from_object(&raw.objects[&id])?) | |
| 315 | } else { | |
| 316 | let edit = selected | |
| 317 | .get(&head) | |
| 318 | .ok_or_else(|| invalid("A new series must start at an edited page"))?; | |
| 319 | let id = ExGuid { | |
| 320 | guid: edit.guid, | |
| 321 | n: 1, | |
| 322 | }; | |
| 323 | if raw.objects.contains_key(&id) { | |
| 324 | return Err(invalid("The new page-series identity already exists")); | |
| 325 | } | |
| 326 | ( | |
| 327 | id, | |
| 328 | PropertyObject { | |
| 329 | jcid: 0x60008, | |
| 330 | bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, | |
| 331 | global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), | |
| 332 | }, | |
| 333 | ) | |
| 334 | }; | |
| 335 | children.push(id); | |
| 336 | let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); | |
| 337 | if !membership_changed | |
| 338 | && !spaces.iter().any(|sid| { | |
| 339 | levels[sid] != original_levels[sid] | |
| 340 | || copies.get(sid).is_some_and(|id| { | |
| 341 | matches!(view.nodes[id].kind, Kind::Metadata { level, .. } | |
| 342 | if level.unwrap_or(1) != levels[sid]) | |
| 343 | }) | |
| 344 | }) | |
| 345 | { | |
| 346 | continue; | |
| 347 | } | |
| 348 | let mut metadata_ids = Vec::new(); | |
| 349 | for sid in &spaces { | |
| 350 | let page = | |
| 351 | index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; | |
| 352 | let page_id = page.roots[&2]; | |
| 353 | let original = PropertyObject::from_object(&page.objects[&page_id])?; | |
| 354 | if old_id.is_none() && *sid == head { | |
| 355 | series.copy_property(&original, 0x18001c65)?; | |
| 356 | } | |
| 357 | if levels[sid] != original_levels[sid] { | |
| 358 | let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; | |
| 359 | metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; | |
| 360 | changes.insert( | |
| 361 | *sid, | |
| 362 | RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), | |
| 363 | ); | |
| 364 | } | |
| 365 | let copy_id = copies | |
| 366 | .get(sid) | |
| 367 | .copied() | |
| 368 | .unwrap_or_else(|| metadata_id(*sid)); | |
| 369 | let mut copy = match raw.objects.get(&copy_id) { | |
| 370 | Some(object) => { | |
| 371 | if object.jcid != 0x20030 | |
| 372 | || (!copies.contains_key(sid) && copy_ids.contains(&copy_id)) | |
| 373 | { | |
| 374 | return Err(invalid("Page metadata identities overlap")); | |
| 375 | } | |
| 376 | PropertyObject::from_object(object)? | |
| 377 | } | |
| 378 | None => original, | |
| 379 | }; | |
| 380 | copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; | |
| 381 | copy.reference(copy_id)?; | |
| 382 | if replacements.insert(copy_id, copy).is_some() { | |
| 383 | return Err(invalid("Page metadata identities overlap")); | |
| 384 | } | |
| 385 | metadata_ids.push(copy_id); | |
| 386 | } | |
| 387 | if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { | |
| 388 | set_references(&mut series, 0x2c001d63, &spaces)?; | |
| 389 | set_references(&mut series, 0x24003442, &metadata_ids)?; | |
| 390 | if replacements.insert(id, series).is_some() { | |
| 391 | return Err(invalid("Page-series and metadata identities overlap")); | |
| 392 | } | |
| 393 | } | |
| 394 | } | |
| 395 | if children != section_node.children { | |
| 396 | let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?; | |
| 397 | set_references(&mut parent, 0x24001c20, &children)?; | |
| 398 | replacements.insert(section_id, parent); | |
| 399 | } | |
| 400 | changes.insert(document.root, RevisionEdit::Update(replacements)); | |
| 401 | Ok(changes) | |
| 402 | }) | |
| 403 | } | |
| 404 | ||
| 405 | /// An empty top-level page with stable identities and creation time. | |
| 406 | /// Retain the intent across retries; an existing page identity rejects duplicate creation. | |
| 407 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 408 | #[serde(deny_unknown_fields)] | |
| 409 | pub struct PageCreation { | |
| 410 | guid: [u8; 16], | |
| 411 | series_guid: [u8; 16], | |
| 412 | before: Option<ExGuid>, | |
| 413 | title: Option<String>, | |
| 414 | author: String, | |
| 415 | created: u32, | |
| 416 | } | |
| 417 | ||
| 418 | impl PageCreation { | |
| 419 | /// Appends a page, or inserts before the first page space of an existing series. | |
| 420 | /// `Some("")` creates an empty title field; `None` creates a page without a title node. | |
| 421 | /// The page has no body outlines or generated date/time text and does not apply a template. | |
| 422 | pub fn new(before: Option<ExGuid>, title: Option<&str>, author: &str) -> Result<Self, Error> { | |
| 423 | let page = Self { | |
| 424 | guid: fresh_guid()?, | |
| 425 | series_guid: fresh_guid()?, | |
| 426 | before, | |
| 427 | title: title.map(str::to_owned), | |
| 428 | author: author.to_owned(), | |
| 429 | created: current_timestamps()?.0, | |
| 430 | }; | |
| 431 | page.validate()?; | |
| 432 | Ok(page) | |
| 433 | } | |
| 434 | ||
| 435 | pub fn space(&self) -> ExGuid { | |
| 436 | ExGuid { | |
| 437 | guid: self.guid, | |
| 438 | n: 1, | |
| 439 | } | |
| 440 | } | |
| 441 | ||
| 442 | pub fn object(&self) -> ExGuid { | |
| 443 | ExGuid { | |
| 444 | guid: self.guid, | |
| 445 | n: 12, | |
| 446 | } | |
| 447 | } | |
| 448 | ||
| 449 | pub fn title_object(&self) -> Option<ExGuid> { | |
| 450 | self.title.as_ref().map(|_| ExGuid { | |
| 451 | guid: self.guid, | |
| 452 | n: 16, | |
| 453 | }) | |
| 454 | } | |
| 455 | ||
| 456 | /// Changes the insertion anchor while retaining all identities and creation metadata. | |
| 457 | pub fn reposition(&self, before: Option<ExGuid>) -> Result<Self, Error> { | |
| 458 | let mut page = self.clone(); | |
| 459 | page.before = before; | |
| 460 | page.validate()?; | |
| 461 | Ok(page) | |
| 462 | } | |
| 463 | ||
| 464 | fn validate(&self) -> Result<(), Error> { | |
| 465 | if self.guid == [0; 16] | |
| 466 | || self.series_guid == [0; 16] | |
| 467 | || self.guid == self.series_guid | |
| 468 | || self.author.contains('\0') | |
| 469 | || self.before.is_some_and(|id| id.guid == [0; 16]) | |
| 470 | || self | |
| 471 | .title | |
| 472 | .as_ref() | |
| 473 | .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) | |
| 474 | { | |
| 475 | return Err(invalid( | |
| 476 | "Use a new page identity, an existing page anchor, and ordinary single-line title text", | |
| 477 | )); | |
| 478 | } | |
| 479 | Ok(()) | |
| 480 | } | |
| 481 | ||
| 482 | pub(crate) fn apply(&self, source: &[u8]) -> Result<Vec<u8>, Error> { | |
| 483 | self.validate()?; | |
| 484 | write_revisions(source, |index| { | |
| 485 | if index.spaces.contains_key(&self.space()) { | |
| 486 | return Err(invalid( | |
| 487 | "This page identity already exists; reconcile the original creation", | |
| 488 | )); | |
| 489 | } | |
| 490 | let document = Document::parse(index)?; | |
| 491 | document.pages()?; | |
| 492 | let section = &document.spaces[&document.root]; | |
| 493 | let view = &section.revisions[&section.contexts[&ExGuid::default()]]; | |
| 494 | let section_id = view.roots[&1]; | |
| 495 | let section_node = &view.nodes[&section_id]; | |
| 496 | if !matches!(section_node.kind, Kind::Section { .. }) | |
| 497 | || section_node.extra[0] | |
| 498 | .iter() | |
| 499 | .any(|field| field.id == 0x88001cde) | |
| 500 | { | |
| 501 | return Err(invalid("Choose an editable section for the new page")); | |
| 502 | } | |
| 503 | let position = match self.before { | |
| 504 | None => section_node.children.len(), | |
| 505 | Some(before) => section_node | |
| 506 | .children | |
| 507 | .iter() | |
| 508 | .position(|id| view.nodes[id].spaces.first() == Some(&before)) | |
| 509 | .ok_or_else(|| { | |
| 510 | invalid("Insert before the first page of an existing series, or append") | |
| 511 | })?, | |
| 512 | }; | |
| 513 | let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; | |
| 514 | let id = |n| ExGuid { guid: self.guid, n }; | |
| 515 | let reference = |n: u32| n.to_le_bytes().to_vec(); | |
| 516 | let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) | |
| 517 | .to_le_bytes() | |
| 518 | .to_vec(); | |
| 519 | let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec()); | |
| 520 | let mut table = BTreeMap::from([(0, self.guid)]); | |
| 521 | let metadata_id = metadata_id(self.space()); | |
| 522 | table.insert(1, metadata_id.guid); | |
| 523 | let table = Arc::new(table); | |
| 524 | let object = |jcid, values: Vec<_>| -> Result<_, Error> { | |
| 525 | Ok(PropertyObject { | |
| 526 | jcid, | |
| 527 | bytes: properties(&values)?, | |
| 528 | global_ids: Arc::clone(&table), | |
| 529 | }) | |
| 530 | }; | |
| 531 | let title = self.title.as_deref().unwrap_or_default().trim_start(); | |
| 532 | let metadata = vec![ | |
| 533 | (0x1c001c30, self.guid.to_vec()), | |
| 534 | (0x1c001cf3, string(title)), | |
| 535 | (0x14001d82, reference(40)), | |
| 536 | (0x1400348b, reference(40)), | |
| 537 | (0x14001dff, reference(1)), | |
| 538 | (0x18001c65, timestamp.clone()), | |
| 539 | ]; | |
| 540 | let mut page = vec![ | |
| 541 | modified(), | |
| 542 | (0x1c001d75, string(&self.author)), | |
| 543 | (0x1c001d3c, string("")), | |
| 544 | ]; | |
| 545 | if self.title.is_some() { | |
| 546 | page.push((0x24001d5f, reference(13))); | |
| 547 | } | |
| 548 | let mut objects = BTreeMap::from([ | |
| 549 | (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?), | |
| 550 | (id(11), object(0x20030, metadata.clone())?), | |
| 551 | (id(12), object(0x6000b, page)?), | |
| 552 | ]); | |
| 553 | if let Some(title) = &self.title { | |
| 554 | for (n, jcid, values) in [ | |
| 555 | ( | |
| 556 | 13, | |
| 557 | 0x6002c, | |
| 558 | vec![ | |
| 559 | modified(), | |
| 560 | (0x24001c20, reference(14)), | |
| 561 | (0x14001c14, 0_f32.to_le_bytes().to_vec()), | |
| 562 | (0x14001c15, 0_f32.to_le_bytes().to_vec()), | |
| 563 | ], | |
| 564 | ), | |
| 565 | ( | |
| 566 | 14, | |
| 567 | 0x6000c, | |
| 568 | vec![ | |
| 569 | modified(), | |
| 570 | (0x24001c20, reference(15)), | |
| 571 | (0x0c001c03, vec![1]), | |
| 572 | ], | |
| 573 | ), | |
| 574 | ( | |
| 575 | 15, | |
| 576 | 0x6000d, | |
| 577 | vec![ | |
| 578 | modified(), | |
| 579 | (0x24001c1f, reference(16)), | |
| 580 | (0x0c001c03, vec![1]), | |
| 581 | (0x20001d78, reference(17)), | |
| 582 | (0x20001d79, reference(17)), | |
| 583 | (0x14001d09, self.created.to_le_bytes().to_vec()), | |
| 584 | (0x88001cb4, vec![]), | |
| 585 | ], | |
| 586 | ), | |
| 587 | ( | |
| 588 | 16, | |
| 589 | 0x6000e, | |
| 590 | vec![ | |
| 591 | modified(), | |
| 592 | (0x1c001c22, string(title)), | |
| 593 | (0x24001e13, reference(19)), | |
| 594 | (0x2000342c, reference(18)), | |
| 595 | (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), | |
| 596 | (0x88001cb4, vec![]), | |
| 597 | ], | |
| 598 | ), | |
| 599 | (17, 0x120001, vec![(0x1c001d75, string(&self.author))]), | |
| 600 | ( | |
| 601 | 18, | |
| 602 | 0x12004d, | |
| 603 | vec![ | |
| 604 | (0x1c00345a, string("PageTitle")), | |
| 605 | (0x1c001c0a, string("Calibri")), | |
| 606 | (0x10001c0b, 34_u16.to_le_bytes().to_vec()), | |
| 607 | ], | |
| 608 | ), | |
| 609 | (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), | |
| 610 | ] { | |
| 611 | objects.insert(id(n), object(jcid, values)?); | |
| 612 | } | |
| 613 | } | |
| 614 | let series = object( | |
| 615 | 0x60008, | |
| 616 | vec![ | |
| 617 | (0x1c001c30, self.series_guid.to_vec()), | |
| 618 | (0x18001c65, timestamp), | |
| 619 | (0x2c001d63, reference(1)), | |
| 620 | (0x24003442, reference(257)), | |
| 621 | ], | |
| 622 | )?; | |
| 623 | if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) { | |
| 624 | return Err(invalid("The new page's section identities already exist")); | |
| 625 | } | |
| 626 | let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?; | |
| 627 | let mut children = section_node.children.clone(); | |
| 628 | children.insert(position, id(2)); | |
| 629 | let mut references = Vec::new(); | |
| 630 | for child in children { | |
| 631 | references.extend_from_slice(&parent.reference(child)?); | |
| 632 | } | |
| 633 | parent.set(&[(0x24001c20, &references)])?; | |
| 634 | Ok(BTreeMap::from([ | |
| 635 | ( | |
| 636 | document.root, | |
| 637 | RevisionEdit::Update(BTreeMap::from([ | |
| 638 | (section_id, parent), | |
| 639 | (id(2), series), | |
| 640 | (metadata_id, object(0x20030, metadata)?), | |
| 641 | ])), | |
| 642 | ), | |
| 643 | ( | |
| 644 | self.space(), | |
| 645 | RevisionEdit::Create { | |
| 646 | roots: BTreeMap::from([(1, id(10)), (2, id(11))]), | |
| 647 | objects, | |
| 648 | }, | |
| 649 | ), | |
| 650 | ])) | |
| 651 | }) | |
| 652 | } | |
| 653 | } | |
| 654 | ||
| 655 | #[cfg(test)] | |
| 656 | mod tests { | |
| 657 | use super::*; | |
| 658 | use crate::{RevisionIndex, Store}; | |
| 659 | ||
| 660 | #[test] | |
| 661 | fn page_movement_restores_an_optional_metadata_cache_from_history() { | |
| 662 | let source = | |
| 663 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | |
| 664 | let store = Store::parse(source).unwrap(); | |
| 665 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 666 | let document = Document::parse(&index).unwrap(); | |
| 667 | let sid = document.pages().unwrap()[3].0; | |
| 668 | let section = &document.spaces[&document.root]; | |
| 669 | let view = &section.revisions[&section.contexts[&ExGuid::default()]]; | |
| 670 | let series_id = view.nodes[&view.roots[&1]].children[3]; | |
| 671 | let copy_id = metadata_id(sid); | |
| 672 | for unknown in [false, true] { | |
| 673 | let source = if unknown { | |
| 674 | write_revisions(source, |index| { | |
| 675 | let raw = index.resolve( | |
| 676 | index.root, | |
| 677 | index.spaces[&index.root].labels[&(ExGuid::default(), 1)], | |
| 678 | )?; | |
| 679 | let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?; | |
| 680 | copy.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; | |
| 681 | Ok(BTreeMap::from([( | |
| 682 | index.root, | |
| 683 | RevisionEdit::Update(BTreeMap::from([(copy_id, copy)])), | |
| 684 | )])) | |
| 685 | }) | |
| 686 | .unwrap() | |
| 687 | } else { | |
| 688 | source.to_vec() | |
| 689 | }; | |
| 690 | let source = write_revisions(&source, |index| { | |
| 691 | let raw = index.resolve( | |
| 692 | index.root, | |
| 693 | index.spaces[&index.root].labels[&(ExGuid::default(), 1)], | |
| 694 | )?; | |
| 695 | let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; | |
| 696 | series.remove(&[0x24003442])?; | |
| 697 | Ok(BTreeMap::from([( | |
| 698 | index.root, | |
| 699 | RevisionEdit::Update(BTreeMap::from([(series_id, series)])), | |
| 700 | )])) | |
| 701 | }) | |
| 702 | .unwrap(); | |
| 703 | for changed in [false, true] { | |
| 704 | for unrelated_edit in [false, true] { | |
| 705 | let result = write_revisions(&source, |index| { | |
| 706 | let raw = index.resolve( | |
| 707 | index.root, | |
| 708 | index.spaces[&index.root].labels[&(ExGuid::default(), 1)], | |
| 709 | )?; | |
| 710 | assert!(!raw.reachable()?.contains(&copy_id)); | |
| 711 | let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?; | |
| 712 | if changed { | |
| 713 | copy.set(&[(0x14001dff, &2_u32.to_le_bytes())])?; | |
| 714 | } | |
| 715 | let mut objects = BTreeMap::from([(copy_id, copy)]); | |
| 716 | if unrelated_edit { | |
| 717 | let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; | |
| 718 | series.set(&[(0x1400abcd, &88_u32.to_le_bytes())])?; | |
| 719 | objects.insert(series_id, series); | |
| 720 | } | |
| 721 | Ok(BTreeMap::from([( | |
| 722 | index.root, | |
| 723 | RevisionEdit::Update(objects), | |
| 724 | )])) | |
| 725 | }); | |
| 726 | assert_eq!( | |
| 727 | result.unwrap_err().message, | |
| 728 | "Edited object is not reachable in the resulting revision" | |
| 729 | ); | |
| 730 | } | |
| 731 | } | |
| 732 | let written = | |
| 733 | edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap(); | |
| 734 | let store = Store::parse(&written).unwrap(); | |
| 735 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 736 | index.validate_current().unwrap(); | |
| 737 | assert_eq!(Document::parse(&index).unwrap().pages().unwrap().len(), 9); | |
| 738 | let current = index | |
| 739 | .resolve( | |
| 740 | index.root, | |
| 741 | index.spaces[&index.root].labels[&(ExGuid::default(), 1)], | |
| 742 | ) | |
| 743 | .unwrap(); | |
| 744 | assert!(current.reachable().unwrap().contains(&copy_id)); | |
| 745 | let copy = PropertyObject::from_object(&current.objects[&copy_id]).unwrap(); | |
| 746 | let properties = crate::PropertySets::parse(&copy.bytes).unwrap(); | |
| 747 | assert_eq!(unknown, properties.sets[0].iter().any(|field| field.id == 0x1400abcd | |
| 748 | && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); | |
| 749 | let old_store = Store::parse(&source).unwrap(); | |
| 750 | let old_index = RevisionIndex::parse(&old_store).unwrap(); | |
| 751 | for (space, revisions) in &old_index.spaces { | |
| 752 | for revision in revisions.revisions.keys() { | |
| 753 | assert_eq!( | |
| 754 | format!("{:?}", old_index.resolve(*space, *revision).unwrap()), | |
| 755 | format!("{:?}", index.resolve(*space, *revision).unwrap()) | |
| 756 | ); | |
| 757 | } | |
| 758 | } | |
| 759 | if !unknown && let Some(output) = std::env::var_os("ONESTORE_PAGE_MOVEMENT_OUTPUT") { | |
| 760 | let output = std::path::Path::new(&output); | |
| 761 | assert!(output.is_absolute()); | |
| 762 | for (name, bytes) in [ | |
| 763 | ("optional-cache-source", source), | |
| 764 | ("optional-cache", written), | |
| 765 | ] { | |
| 766 | let path = output.join(name); | |
| 767 | std::fs::create_dir_all(&path).unwrap(); | |
| 768 | std::fs::write(path.join("Lifecycle.one"), bytes).unwrap(); | |
| 769 | } | |
| 770 | } | |
| 771 | } | |
| 772 | } | |
| 773 | ||
| 774 | #[test] | |
| 775 | fn page_movement_rejects_empty_manifests_and_series() { | |
| 776 | let source = | |
| 777 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | |
| 778 | let store = Store::parse(source).unwrap(); | |
| 779 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 780 | let document = Document::parse(&index).unwrap(); | |
| 781 | let sid = document.pages().unwrap()[3].0; | |
| 782 | for empty_series in [false, true] { | |
| 783 | let invalid_source = write_revisions(source, |index| { | |
| 784 | let target = if empty_series { index.root } else { sid }; | |
| 785 | let raw = index.resolve( | |
| 786 | target, | |
| 787 | index.spaces[&target].labels[&(ExGuid::default(), 1)], | |
| 788 | )?; | |
| 789 | let id = if empty_series { | |
| 790 | let space = &document.spaces[&document.root]; | |
| 791 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | |
| 792 | view.nodes[&view.roots[&1]].children[3] | |
| 793 | } else { | |
| 794 | raw.roots[&1] | |
| 795 | }; | |
| 796 | let mut object = PropertyObject::from_object(&raw.objects[&id])?; | |
| 797 | set_references( | |
| 798 | &mut object, | |
| 799 | if empty_series { 0x2c001d63 } else { 0x24001c1f }, | |
| 800 | &[], | |
| 801 | )?; | |
| 802 | Ok(BTreeMap::from([( | |
| 803 | target, | |
| 804 | RevisionEdit::Update(BTreeMap::from([(id, object)])), | |
| 805 | )])) | |
| 806 | }) | |
| 807 | .unwrap(); | |
| 808 | assert!( | |
| 809 | edit_pages( | |
| 810 | &invalid_source, | |
| 811 | &[PageEdit::set_level(sid, 2).unwrap()], | |
| 812 | &[] | |
| 813 | ) | |
| 814 | .is_err() | |
| 815 | ); | |
| 816 | } | |
| 817 | } | |
| 818 | ||
| 819 | #[test] | |
| 820 | fn page_moves_retain_unknown_series_and_metadata_properties() { | |
| 821 | let source = | |
| 822 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | |
| 823 | let store = Store::parse(source).unwrap(); | |
| 824 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 825 | let document = Document::parse(&index).unwrap(); | |
| 826 | let pages = document.pages().unwrap(); | |
| 827 | let section = &document.spaces[&document.root]; | |
| 828 | let view = &section.revisions[&section.contexts[&ExGuid::default()]]; | |
| 829 | let series_id = view.nodes[&view.roots[&1]].children[3]; | |
| 830 | let field = view.nodes[&series_id].extra[0] | |
| 831 | .iter() | |
| 832 | .find(|f| f.id == 0x24003442) | |
| 833 | .unwrap(); | |
| 834 | let FieldValue::Objects(copies) = &field.value else { | |
| 835 | panic!() | |
| 836 | }; | |
| 837 | let copy_id = copies[0]; | |
| 838 | let source = write_revisions(source, |index| { | |
| 839 | let raw = index.resolve( | |
| 840 | index.root, | |
| 841 | index.spaces[&index.root].labels[&(ExGuid::default(), 1)], | |
| 842 | )?; | |
| 843 | let mut objects = BTreeMap::new(); | |
| 844 | for id in [series_id, copy_id] { | |
| 845 | let mut object = PropertyObject::from_object(&raw.objects[&id])?; | |
| 846 | object.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; | |
| 847 | objects.insert(id, object); | |
| 848 | } | |
| 849 | Ok(BTreeMap::from([( | |
| 850 | index.root, | |
| 851 | RevisionEdit::Update(objects), | |
| 852 | )])) | |
| 853 | }) | |
| 854 | .unwrap(); | |
| 855 | for edits in [ | |
| 856 | vec![PageEdit::set_level(pages[3].0, 2).unwrap()], | |
| 857 | pages[3..6] | |
| 858 | .iter() | |
| 859 | .enumerate() | |
| 860 | .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap()) | |
| 861 | .collect(), | |
| 862 | ] { | |
| 863 | let written = edit_pages(&source, &edits, &[]).unwrap(); | |
| 864 | let old_store = Store::parse(&source).unwrap(); | |
| 865 | let new_store = Store::parse(&written).unwrap(); | |
| 866 | let old = RevisionIndex::parse(&old_store).unwrap(); | |
| 867 | let new = RevisionIndex::parse(&new_store).unwrap(); | |
| 868 | let old_id = old.spaces[&old.root].labels[&(ExGuid::default(), 1)]; | |
| 869 | let new_id = new.spaces[&new.root].labels[&(ExGuid::default(), 1)]; | |
| 870 | assert_eq!( | |
| 871 | format!("{:?}", old.resolve(old.root, old_id).unwrap()), | |
| 872 | format!("{:?}", new.resolve(new.root, old_id).unwrap()) | |
| 873 | ); | |
| 874 | let current = new.resolve(new.root, new_id).unwrap(); | |
| 875 | for id in [series_id, copy_id] { | |
| 876 | let object = PropertyObject::from_object(&current.objects[&id]).unwrap(); | |
| 877 | let properties = crate::PropertySets::parse(&object.bytes).unwrap(); | |
| 878 | assert!(properties.sets[0].iter().any(|field| field.id == 0x1400abcd | |
| 879 | && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); | |
| 880 | } | |
| 881 | } | |
| 882 | } | |
| 883 | } |
crates/onestore/src/page/mod.rs created+1276| ... | ... | @@ -0,0 +1,1276 @@ |
| 1 | //! An editable page model: outlines, paragraphs, tables and images with their stored | |
| 2 | //! identities, plus everything else on the page retained as `Unsupported`. | |
| 3 | ||
| 4 | use crate::{ | |
| 5 | Error, ExGuid, | |
| 6 | document::{Document, Element, FieldValue, Format, Kind, Layout, Revision, Tag}, | |
| 7 | }; | |
| 8 | use std::{ | |
| 9 | collections::{BTreeMap, BTreeSet}, | |
| 10 | sync::Arc, | |
| 11 | }; | |
| 12 | ||
| 13 | pub mod text; | |
| 14 | pub use text::Paragraph; | |
| 15 | ||
| 16 | /// The role of a title-outline paragraph that displays the page's creation date or time. | |
| 17 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 18 | pub enum DateField { | |
| 19 | Date = 0, | |
| 20 | Time = 1, | |
| 21 | } | |
| 22 | ||
| 23 | pub struct Page { | |
| 24 | pub title: String, | |
| 25 | /// FILETIME ticks from the page's TopologyCreationTimeStamp. | |
| 26 | pub created: Option<u64>, | |
| 27 | pub margin_origin: [f32; 2], | |
| 28 | pub objects: Vec<PageObject>, | |
| 29 | pub definitions: BTreeMap<ExGuid, Definition>, | |
| 30 | } | |
| 31 | ||
| 32 | pub struct Definition { | |
| 33 | pub kind: Kind<'static>, | |
| 34 | pub format: Format, | |
| 35 | } | |
| 36 | ||
| 37 | pub enum PageObject { | |
| 38 | Outline(Outline), | |
| 39 | Title(Title), | |
| 40 | Image(Image), | |
| 41 | Unsupported(Unsupported), | |
| 42 | } | |
| 43 | ||
| 44 | impl PageObject { | |
| 45 | pub fn id(&self) -> ExGuid { | |
| 46 | match self { | |
| 47 | Self::Outline(value) => value.id, | |
| 48 | Self::Title(value) => value.id, | |
| 49 | Self::Image(value) => value.id, | |
| 50 | Self::Unsupported(value) => value.id, | |
| 51 | } | |
| 52 | } | |
| 53 | pub fn layout(&self) -> &Layout { | |
| 54 | match self { | |
| 55 | Self::Outline(value) => &value.layout, | |
| 56 | Self::Title(value) => &value.layout, | |
| 57 | Self::Image(value) => &value.layout, | |
| 58 | Self::Unsupported(value) => &value.layout, | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | pub fn layout_mut(&mut self) -> &mut Layout { | |
| 63 | match self { | |
| 64 | Self::Outline(value) => &mut value.layout, | |
| 65 | Self::Title(value) => &mut value.layout, | |
| 66 | Self::Image(value) => &mut value.layout, | |
| 67 | Self::Unsupported(value) => &mut value.layout, | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | pub struct Title { | |
| 73 | pub id: ExGuid, | |
| 74 | pub date: Option<ExGuid>, | |
| 75 | pub layout: Layout, | |
| 76 | pub outlines: Vec<Outline>, | |
| 77 | } | |
| 78 | ||
| 79 | #[derive(Clone)] | |
| 80 | pub struct Outline { | |
| 81 | pub id: ExGuid, | |
| 82 | pub title: bool, | |
| 83 | pub min_width: Option<f32>, | |
| 84 | pub layout: Layout, | |
| 85 | pub indents: Vec<f32>, | |
| 86 | pub paragraphs: Vec<PageParagraph>, | |
| 87 | pub unsupported: Vec<Unsupported>, | |
| 88 | } | |
| 89 | ||
| 90 | #[derive(Clone, Debug, PartialEq)] | |
| 91 | pub struct PageParagraph { | |
| 92 | pub id: ExGuid, | |
| 93 | pub parent: Option<ExGuid>, | |
| 94 | pub level: u32, | |
| 95 | pub style: Option<ExGuid>, | |
| 96 | pub format: Format, | |
| 97 | pub content: ParagraphContent, | |
| 98 | pub lists: Vec<ExGuid>, | |
| 99 | pub tags: Vec<Tag>, | |
| 100 | pub collapsed: bool, | |
| 101 | } | |
| 102 | ||
| 103 | #[derive(Clone, Debug, PartialEq)] | |
| 104 | pub enum ParagraphContent { | |
| 105 | Text(TextObject), | |
| 106 | Table(Table), | |
| 107 | Unsupported(Unsupported), | |
| 108 | } | |
| 109 | ||
| 110 | #[derive(Clone, Debug, PartialEq)] | |
| 111 | pub struct Table { | |
| 112 | pub id: ExGuid, | |
| 113 | pub columns: Vec<TableColumn>, | |
| 114 | pub rows: Vec<TableRow>, | |
| 115 | pub borders: Option<bool>, | |
| 116 | pub layout: Layout, | |
| 117 | pub tags: Vec<Tag>, | |
| 118 | } | |
| 119 | ||
| 120 | #[derive(Clone, Debug, PartialEq)] | |
| 121 | pub struct TableColumn { | |
| 122 | pub width: f32, | |
| 123 | pub locked: bool, | |
| 124 | } | |
| 125 | ||
| 126 | #[derive(Clone, Debug, PartialEq)] | |
| 127 | pub struct TableRow { | |
| 128 | pub id: ExGuid, | |
| 129 | pub cells: Vec<TableCell>, | |
| 130 | } | |
| 131 | ||
| 132 | #[derive(Clone, Debug, PartialEq)] | |
| 133 | pub struct TableCell { | |
| 134 | pub id: ExGuid, | |
| 135 | pub layout: Layout, | |
| 136 | pub indents: Vec<f32>, | |
| 137 | pub shading: Option<u32>, | |
| 138 | pub paragraphs: Vec<PageParagraph>, | |
| 139 | pub unsupported: Vec<Unsupported>, | |
| 140 | } | |
| 141 | ||
| 142 | #[derive(Clone, Debug, PartialEq)] | |
| 143 | pub struct TextObject { | |
| 144 | pub id: ExGuid, | |
| 145 | pub date_field: Option<DateField>, | |
| 146 | pub text: Paragraph, | |
| 147 | pub tags: Vec<Tag>, | |
| 148 | } | |
| 149 | ||
| 150 | pub struct Image { | |
| 151 | pub id: ExGuid, | |
| 152 | pub layout: Layout, | |
| 153 | pub bytes: Option<Arc<[u8]>>, | |
| 154 | pub alt: Option<String>, | |
| 155 | pub background: bool, | |
| 156 | } | |
| 157 | ||
| 158 | #[derive(Clone, Debug, PartialEq)] | |
| 159 | pub struct Unsupported { | |
| 160 | pub id: ExGuid, | |
| 161 | pub jcid: u32, | |
| 162 | pub layout: Layout, | |
| 163 | } | |
| 164 | ||
| 165 | impl Page { | |
| 166 | pub fn from_document(document: &Document<'_>, title: &str) -> Result<Self, Error> { | |
| 167 | let mut selected = None; | |
| 168 | for (space, id) in document.pages()? { | |
| 169 | let revision = document.active(space)?; | |
| 170 | if page_title(revision, id) == Some(title) { | |
| 171 | if selected.is_some() { | |
| 172 | return Err(Error { | |
| 173 | offset: 0, | |
| 174 | message: "More than one active page has the requested title", | |
| 175 | }); | |
| 176 | } | |
| 177 | selected = Some((revision, id)); | |
| 178 | } | |
| 179 | } | |
| 180 | let (revision, id) = selected.ok_or(Error { | |
| 181 | offset: 0, | |
| 182 | message: "No active page has the requested title", | |
| 183 | })?; | |
| 184 | Self::from_revision(revision, id) | |
| 185 | } | |
| 186 | ||
| 187 | /// The single active page declared in one page object space. | |
| 188 | pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> { | |
| 189 | let pages = document.pages_in(space)?; | |
| 190 | let [id] = pages.as_slice() else { | |
| 191 | return Err(Error { | |
| 192 | offset: 0, | |
| 193 | message: "Choose an object space containing one active page", | |
| 194 | }); | |
| 195 | }; | |
| 196 | Self::from_revision(document.active(space)?, *id) | |
| 197 | } | |
| 198 | ||
| 199 | pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result<Self, Error> { | |
| 200 | let invalid = |message| Error { offset: 0, message }; | |
| 201 | let root = revision | |
| 202 | .nodes | |
| 203 | .get(&id) | |
| 204 | .ok_or_else(|| invalid("Missing canvas page"))?; | |
| 205 | let Kind::Page { | |
| 206 | margin_origin_x, | |
| 207 | margin_origin_y, | |
| 208 | .. | |
| 209 | } = &root.kind | |
| 210 | else { | |
| 211 | return Err(invalid("Canvas root is not a page")); | |
| 212 | }; | |
| 213 | let mut page = Self { | |
| 214 | title: page_title(revision, id).unwrap_or_default().to_owned(), | |
| 215 | created: revision | |
| 216 | .roots | |
| 217 | .get(&2) | |
| 218 | .and_then(|id| revision.nodes.get(id)) | |
| 219 | .filter(|node| matches!(node.kind, Kind::Metadata { .. })) | |
| 220 | .and_then(|node| node.extra.first()) | |
| 221 | .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65)) | |
| 222 | .map(|field| { | |
| 223 | let FieldValue::Bytes(bytes) = field.value else { | |
| 224 | return Err(invalid("The page creation date is damaged")); | |
| 225 | }; | |
| 226 | Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| { | |
| 227 | invalid("The page creation date is damaged") | |
| 228 | })?)) | |
| 229 | }) | |
| 230 | .transpose()?, | |
| 231 | margin_origin: [ | |
| 232 | margin_origin_x.unwrap_or(0.0), | |
| 233 | margin_origin_y.unwrap_or(0.0), | |
| 234 | ], | |
| 235 | objects: Vec::new(), | |
| 236 | definitions: BTreeMap::new(), | |
| 237 | }; | |
| 238 | let mut roots: Vec<_> = root | |
| 239 | .children | |
| 240 | .iter() | |
| 241 | .chain(&root.structure) | |
| 242 | .rev() | |
| 243 | .map(|id| (*id, None)) | |
| 244 | .collect(); | |
| 245 | let mut seen = BTreeSet::new(); | |
| 246 | while let Some((id, title_index)) = roots.pop() { | |
| 247 | if !seen.insert(id) { | |
| 248 | return Err(invalid("Repeated canvas page object")); | |
| 249 | } | |
| 250 | let node = revision | |
| 251 | .nodes | |
| 252 | .get(&id) | |
| 253 | .ok_or_else(|| invalid("Missing canvas page object"))?; | |
| 254 | if title_index.is_some() && !matches!(node.kind, Kind::Outline { .. }) { | |
| 255 | return Err(invalid("Canvas title child is not an outline")); | |
| 256 | } | |
| 257 | match &node.kind { | |
| 258 | Kind::Title => { | |
| 259 | let index = page.objects.len(); | |
| 260 | page.objects.push(PageObject::Title(Title { | |
| 261 | id, | |
| 262 | date: None, | |
| 263 | layout: node.layout.clone(), | |
| 264 | outlines: Vec::new(), | |
| 265 | })); | |
| 266 | roots.extend(node.children.iter().rev().map(|id| (*id, Some(index)))); | |
| 267 | } | |
| 268 | Kind::Outline { indents } => { | |
| 269 | let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default(); | |
| 270 | if let Some(index) = title_index | |
| 271 | && fields.iter().any(|field| { | |
| 272 | field.id == 0x88001cb5 && matches!(field.value, FieldValue::NoData) | |
| 273 | }) | |
| 274 | { | |
| 275 | let PageObject::Title(title) = &mut page.objects[index] else { | |
| 276 | unreachable!() | |
| 277 | }; | |
| 278 | if title.date.replace(id).is_some() { | |
| 279 | return Err(invalid("The page has more than one date field")); | |
| 280 | } | |
| 281 | } | |
| 282 | let min_width = fields | |
| 283 | .iter() | |
| 284 | .find(|field| field.id == 0x14001cec) | |
| 285 | .map(|field| { | |
| 286 | let FieldValue::Bytes(bytes) = field.value else { | |
| 287 | return Err(invalid("Invalid canvas minimum outline width")); | |
| 288 | }; | |
| 289 | let width = | |
| 290 | f32::from_le_bytes(bytes.try_into().map_err(|_| { | |
| 291 | invalid("Invalid canvas minimum outline width") | |
| 292 | })?) * 36.0; | |
| 293 | if !width.is_finite() || width <= 0.0 { | |
| 294 | return Err(invalid("Invalid canvas minimum outline width")); | |
| 295 | } | |
| 296 | Ok(width) | |
| 297 | }) | |
| 298 | .transpose()?; | |
| 299 | let mut outline = Outline { | |
| 300 | id, | |
| 301 | title: fields.iter().any(|field| { | |
| 302 | field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData) | |
| 303 | }), | |
| 304 | min_width, | |
| 305 | layout: node.layout.clone(), | |
| 306 | indents: indents.clone(), | |
| 307 | paragraphs: Vec::new(), | |
| 308 | unsupported: Vec::new(), | |
| 309 | }; | |
| 310 | (outline.paragraphs, outline.unsupported) = read_paragraphs( | |
| 311 | revision, | |
| 312 | node, | |
| 313 | &node.format, | |
| 314 | &mut page.definitions, | |
| 315 | &mut seen, | |
| 316 | 0, | |
| 317 | )?; | |
| 318 | if let Some(index) = title_index { | |
| 319 | let PageObject::Title(title) = &mut page.objects[index] else { | |
| 320 | unreachable!() | |
| 321 | }; | |
| 322 | title.outlines.push(outline); | |
| 323 | } else { | |
| 324 | page.objects.push(PageObject::Outline(outline)); | |
| 325 | } | |
| 326 | } | |
| 327 | Kind::Image { | |
| 328 | container, | |
| 329 | alt, | |
| 330 | background, | |
| 331 | .. | |
| 332 | } => { | |
| 333 | let bytes = if let Some(container) = container { | |
| 334 | let data = revision | |
| 335 | .nodes | |
| 336 | .get(container) | |
| 337 | .ok_or_else(|| invalid("Missing canvas image data"))?; | |
| 338 | match &data.kind { | |
| 339 | Kind::File { payload, .. } => payload.map(Arc::from), | |
| 340 | _ => return Err(invalid("Canvas image data has the wrong type")), | |
| 341 | } | |
| 342 | } else { | |
| 343 | None | |
| 344 | }; | |
| 345 | page.objects.push(PageObject::Image(Image { | |
| 346 | id, | |
| 347 | layout: node.layout.clone(), | |
| 348 | bytes, | |
| 349 | alt: alt.clone(), | |
| 350 | background: background.unwrap_or(false), | |
| 351 | })); | |
| 352 | } | |
| 353 | _ => page.objects.push(PageObject::Unsupported(Unsupported { | |
| 354 | id, | |
| 355 | jcid: node.jcid, | |
| 356 | layout: node.layout.clone(), | |
| 357 | })), | |
| 358 | } | |
| 359 | } | |
| 360 | Ok(page) | |
| 361 | } | |
| 362 | } | |
| 363 | ||
| 364 | fn read_paragraphs( | |
| 365 | revision: &Revision<'_>, | |
| 366 | container: &Element<'_>, | |
| 367 | inherited: &Format, | |
| 368 | definitions: &mut BTreeMap<ExGuid, Definition>, | |
| 369 | seen: &mut BTreeSet<ExGuid>, | |
| 370 | depth: usize, | |
| 371 | ) -> Result<(Vec<PageParagraph>, Vec<Unsupported>), Error> { | |
| 372 | let invalid = |message| Error { offset: 0, message }; | |
| 373 | let default_format = Format::default(); | |
| 374 | let style = |id: Option<ExGuid>| -> Result<&Format, Error> { | |
| 375 | let Some(id) = id else { | |
| 376 | return Ok(&default_format); | |
| 377 | }; | |
| 378 | let node = revision | |
| 379 | .nodes | |
| 380 | .get(&id) | |
| 381 | .ok_or_else(|| invalid("Missing canvas paragraph style"))?; | |
| 382 | if !matches!(node.kind, Kind::Style { .. }) { | |
| 383 | return Err(invalid("Canvas paragraph style has the wrong type")); | |
| 384 | } | |
| 385 | Ok(&node.format) | |
| 386 | }; | |
| 387 | ||
| 388 | let mut paragraphs = Vec::new(); | |
| 389 | let mut unsupported = Vec::new(); | |
| 390 | let mut pending: Vec<_> = container | |
| 391 | .children | |
| 392 | .iter() | |
| 393 | .rev() | |
| 394 | .map(|id| { | |
| 395 | ( | |
| 396 | *id, | |
| 397 | None, | |
| 398 | u32::from(container.child_level.unwrap_or(0)), | |
| 399 | inherited.clone(), | |
| 400 | ) | |
| 401 | }) | |
| 402 | .collect(); | |
| 403 | while let Some((id, parent, level, inherited)) = pending.pop() { | |
| 404 | if !seen.insert(id) { | |
| 405 | return Err(invalid("Repeated canvas outline object")); | |
| 406 | } | |
| 407 | let node = revision | |
| 408 | .nodes | |
| 409 | .get(&id) | |
| 410 | .ok_or_else(|| invalid("Missing canvas outline object"))?; | |
| 411 | let next_level = level | |
| 412 | .checked_add(u32::from(node.child_level.unwrap_or(0))) | |
| 413 | .ok_or_else(|| invalid("Canvas outline level overflow"))?; | |
| 414 | match &node.kind { | |
| 415 | Kind::Paragraph { | |
| 416 | lists, | |
| 417 | paragraph_style, | |
| 418 | collapse_state, | |
| 419 | } => { | |
| 420 | let format = node | |
| 421 | .format | |
| 422 | .inherit(style(*paragraph_style)?) | |
| 423 | .inherit(&inherited); | |
| 424 | let mut base_style = *paragraph_style; | |
| 425 | let [content_id] = node.content.as_slice() else { | |
| 426 | return Err(invalid("A paragraph must contain one content object")); | |
| 427 | }; | |
| 428 | if !seen.insert(*content_id) { | |
| 429 | return Err(invalid("Repeated canvas paragraph content")); | |
| 430 | } | |
| 431 | let content = revision | |
| 432 | .nodes | |
| 433 | .get(content_id) | |
| 434 | .ok_or_else(|| invalid("Missing canvas paragraph content"))?; | |
| 435 | let content = if let Kind::RichText { | |
| 436 | paragraph_style, .. | |
| 437 | } = &content.kind | |
| 438 | { | |
| 439 | base_style = paragraph_style.or(base_style); | |
| 440 | let runs = revision.text_runs(*content_id)?; | |
| 441 | let text_content = if runs.is_empty() { | |
| 442 | Paragraph::new( | |
| 443 | String::new(), | |
| 444 | content | |
| 445 | .format | |
| 446 | .inherit(style(*paragraph_style)?) | |
| 447 | .inherit(&format), | |
| 448 | ) | |
| 449 | } else { | |
| 450 | Paragraph::from_runs( | |
| 451 | runs.into_iter() | |
| 452 | .map(|run| (run.text.to_owned(), run.format.inherit(&format))), | |
| 453 | ) | |
| 454 | }; | |
| 455 | ParagraphContent::Text(TextObject { | |
| 456 | id: *content_id, | |
| 457 | date_field: content | |
| 458 | .extra | |
| 459 | .first() | |
| 460 | .map(Vec::as_slice) | |
| 461 | .unwrap_or_default() | |
| 462 | .iter() | |
| 463 | .filter(|field| matches!(field.value, FieldValue::NoData)) | |
| 464 | .filter_map(|field| match field.id { | |
| 465 | 0x88001cb5 => Some(DateField::Date), | |
| 466 | 0x88001c87 => Some(DateField::Time), | |
| 467 | _ => None, | |
| 468 | }) | |
| 469 | .try_fold(None, |previous, field| { | |
| 470 | if previous.is_some() { | |
| 471 | Err(invalid("The page date field has conflicting roles")) | |
| 472 | } else { | |
| 473 | Ok(Some(field)) | |
| 474 | } | |
| 475 | })?, | |
| 476 | text: text_content, | |
| 477 | tags: content.tags.clone(), | |
| 478 | }) | |
| 479 | } else if matches!(content.kind, Kind::Table { .. }) { | |
| 480 | ParagraphContent::Table(read_table( | |
| 481 | revision, | |
| 482 | *content_id, | |
| 483 | &content.format.inherit(&format), | |
| 484 | definitions, | |
| 485 | seen, | |
| 486 | depth + 1, | |
| 487 | )?) | |
| 488 | } else { | |
| 489 | ParagraphContent::Unsupported(Unsupported { | |
| 490 | id: *content_id, | |
| 491 | jcid: content.jcid, | |
| 492 | layout: content.layout.clone(), | |
| 493 | }) | |
| 494 | }; | |
| 495 | for (id, is_list) in lists.iter().map(|id| (id, true)).chain( | |
| 496 | node.tags | |
| 497 | .iter() | |
| 498 | .chain(match &content { | |
| 499 | ParagraphContent::Text(text) => text.tags.as_slice(), | |
| 500 | ParagraphContent::Table(table) => table.tags.as_slice(), | |
| 501 | ParagraphContent::Unsupported(_) => &[], | |
| 502 | }) | |
| 503 | .filter_map(|tag| tag.definition.as_ref()) | |
| 504 | .map(|id| (id, false)), | |
| 505 | ) { | |
| 506 | let definition = revision | |
| 507 | .nodes | |
| 508 | .get(id) | |
| 509 | .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; | |
| 510 | let kind = match &definition.kind { | |
| 511 | Kind::List { | |
| 512 | font, | |
| 513 | format, | |
| 514 | restart, | |
| 515 | bullet, | |
| 516 | } if is_list => Kind::List { | |
| 517 | font: font.clone(), | |
| 518 | format: format.clone(), | |
| 519 | restart: *restart, | |
| 520 | bullet: *bullet, | |
| 521 | }, | |
| 522 | Kind::TagDefinition { | |
| 523 | label, | |
| 524 | action_type, | |
| 525 | shape, | |
| 526 | color, | |
| 527 | highlight, | |
| 528 | } if !is_list => Kind::TagDefinition { | |
| 529 | label: label.clone(), | |
| 530 | action_type: *action_type, | |
| 531 | shape: *shape, | |
| 532 | color: *color, | |
| 533 | highlight: *highlight, | |
| 534 | }, | |
| 535 | _ => { | |
| 536 | return Err(invalid( | |
| 537 | "Canvas list or tag definition has the wrong type", | |
| 538 | )); | |
| 539 | } | |
| 540 | }; | |
| 541 | definitions.insert( | |
| 542 | *id, | |
| 543 | Definition { | |
| 544 | kind, | |
| 545 | format: definition.format.clone(), | |
| 546 | }, | |
| 547 | ); | |
| 548 | } | |
| 549 | if let Some(id) = base_style { | |
| 550 | let definition = revision | |
| 551 | .nodes | |
| 552 | .get(&id) | |
| 553 | .ok_or_else(|| invalid("Missing canvas paragraph style"))?; | |
| 554 | let Kind::Style { name } = &definition.kind else { | |
| 555 | return Err(invalid("Canvas paragraph style has the wrong type")); | |
| 556 | }; | |
| 557 | definitions.entry(id).or_insert_with(|| Definition { | |
| 558 | kind: Kind::Style { name: name.clone() }, | |
| 559 | format: definition.format.clone(), | |
| 560 | }); | |
| 561 | } | |
| 562 | paragraphs.push(PageParagraph { | |
| 563 | id, | |
| 564 | parent, | |
| 565 | level, | |
| 566 | style: base_style, | |
| 567 | format: format.clone(), | |
| 568 | content, | |
| 569 | lists: lists.clone(), | |
| 570 | tags: node.tags.clone(), | |
| 571 | collapsed: *collapse_state == Some(1), | |
| 572 | }); | |
| 573 | pending.extend( | |
| 574 | node.children | |
| 575 | .iter() | |
| 576 | .rev() | |
| 577 | .map(|child| (*child, Some(id), next_level, format.clone())), | |
| 578 | ); | |
| 579 | } | |
| 580 | Kind::OutlineGroup => pending.extend( | |
| 581 | node.children | |
| 582 | .iter() | |
| 583 | .rev() | |
| 584 | .map(|child| (*child, parent, next_level, node.format.inherit(&inherited))), | |
| 585 | ), | |
| 586 | _ => unsupported.push(Unsupported { | |
| 587 | id, | |
| 588 | jcid: node.jcid, | |
| 589 | layout: node.layout.clone(), | |
| 590 | }), | |
| 591 | } | |
| 592 | } | |
| 593 | Ok((paragraphs, unsupported)) | |
| 594 | } | |
| 595 | ||
| 596 | fn read_table( | |
| 597 | revision: &Revision<'_>, | |
| 598 | id: ExGuid, | |
| 599 | inherited: &Format, | |
| 600 | definitions: &mut BTreeMap<ExGuid, Definition>, | |
| 601 | seen: &mut BTreeSet<ExGuid>, | |
| 602 | depth: usize, | |
| 603 | ) -> Result<Table, Error> { | |
| 604 | let invalid = |message| Error { offset: 0, message }; | |
| 605 | if depth > 64 { | |
| 606 | return Err(invalid("Tables are nested too deeply")); | |
| 607 | } | |
| 608 | let node = &revision.nodes[&id]; | |
| 609 | let Kind::Table { | |
| 610 | rows, | |
| 611 | columns, | |
| 612 | widths, | |
| 613 | locked, | |
| 614 | borders, | |
| 615 | } = &node.kind | |
| 616 | else { | |
| 617 | unreachable!() | |
| 618 | }; | |
| 619 | if rows.map(|n| n as usize) != Some(node.children.len()) | |
| 620 | || node.children.is_empty() | |
| 621 | || columns.map(|n| n as usize) != Some(widths.len()) | |
| 622 | || widths.is_empty() | |
| 623 | || (!locked.is_empty() && locked.len() != widths.len()) | |
| 624 | || widths.iter().any(|w| !w.is_finite() || *w < 36.0) | |
| 625 | { | |
| 626 | return Err(invalid("Table dimensions are inconsistent")); | |
| 627 | } | |
| 628 | let mut table_rows = Vec::new(); | |
| 629 | for row_id in &node.children { | |
| 630 | if !seen.insert(*row_id) { | |
| 631 | return Err(invalid("Repeated canvas table row")); | |
| 632 | } | |
| 633 | let row = revision | |
| 634 | .nodes | |
| 635 | .get(row_id) | |
| 636 | .ok_or_else(|| invalid("Missing canvas table row"))?; | |
| 637 | if !matches!(row.kind, Kind::Row) || row.children.len() != widths.len() { | |
| 638 | return Err(invalid("Table row dimensions are inconsistent")); | |
| 639 | } | |
| 640 | let mut cells = Vec::new(); | |
| 641 | for cell_id in &row.children { | |
| 642 | if !seen.insert(*cell_id) { | |
| 643 | return Err(invalid("Repeated canvas table cell")); | |
| 644 | } | |
| 645 | let cell = revision | |
| 646 | .nodes | |
| 647 | .get(cell_id) | |
| 648 | .ok_or_else(|| invalid("Missing canvas table cell"))?; | |
| 649 | if cell.children.is_empty() { | |
| 650 | return Err(invalid("A table cell has no paragraphs")); | |
| 651 | } | |
| 652 | let Kind::Cell { shading, indents } = &cell.kind else { | |
| 653 | return Err(invalid("A table row contains an invalid cell")); | |
| 654 | }; | |
| 655 | let (paragraphs, unsupported) = read_paragraphs( | |
| 656 | revision, | |
| 657 | cell, | |
| 658 | &cell.format.inherit(inherited), | |
| 659 | definitions, | |
| 660 | seen, | |
| 661 | depth, | |
| 662 | )?; | |
| 663 | cells.push(TableCell { | |
| 664 | id: *cell_id, | |
| 665 | layout: cell.layout.clone(), | |
| 666 | indents: indents.clone(), | |
| 667 | shading: *shading, | |
| 668 | paragraphs, | |
| 669 | unsupported, | |
| 670 | }); | |
| 671 | } | |
| 672 | table_rows.push(TableRow { id: *row_id, cells }); | |
| 673 | } | |
| 674 | Ok(Table { | |
| 675 | id, | |
| 676 | columns: widths | |
| 677 | .iter() | |
| 678 | .enumerate() | |
| 679 | .map(|(index, &width)| TableColumn { | |
| 680 | width, | |
| 681 | locked: locked.get(index).copied().unwrap_or(false), | |
| 682 | }) | |
| 683 | .collect(), | |
| 684 | rows: table_rows, | |
| 685 | borders: *borders, | |
| 686 | layout: node.layout.clone(), | |
| 687 | tags: node.tags.clone(), | |
| 688 | }) | |
| 689 | } | |
| 690 | ||
| 691 | fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> { | |
| 692 | revision | |
| 693 | .roots | |
| 694 | .get(&2) | |
| 695 | .and_then(|id| revision.nodes.get(id)) | |
| 696 | .and_then(|node| { | |
| 697 | if let Kind::Metadata { title, .. } = &node.kind { | |
| 698 | title.as_deref() | |
| 699 | } else { | |
| 700 | None | |
| 701 | } | |
| 702 | }) | |
| 703 | .or_else(|| { | |
| 704 | if let Kind::Page { | |
| 705 | alternate_title, .. | |
| 706 | } = &revision.nodes.get(&id)?.kind | |
| 707 | { | |
| 708 | alternate_title.as_deref() | |
| 709 | } else { | |
| 710 | None | |
| 711 | } | |
| 712 | }) | |
| 713 | } | |
| 714 | ||
| 715 | impl PageParagraph { | |
| 716 | pub fn text(&self) -> Option<&TextObject> { | |
| 717 | match &self.content { | |
| 718 | ParagraphContent::Text(text) => Some(text), | |
| 719 | _ => None, | |
| 720 | } | |
| 721 | } | |
| 722 | pub fn text_mut(&mut self) -> Option<&mut TextObject> { | |
| 723 | match &mut self.content { | |
| 724 | ParagraphContent::Text(text) => Some(text), | |
| 725 | _ => None, | |
| 726 | } | |
| 727 | } | |
| 728 | } | |
| 729 | ||
| 730 | #[cfg(test)] | |
| 731 | mod tests { | |
| 732 | use super::*; | |
| 733 | use crate::document::{Element, TextRun}; | |
| 734 | ||
| 735 | fn id(n: u32) -> ExGuid { | |
| 736 | ExGuid { | |
| 737 | n, | |
| 738 | ..ExGuid::default() | |
| 739 | } | |
| 740 | } | |
| 741 | ||
| 742 | fn element(kind: Kind<'_>) -> Element<'_> { | |
| 743 | Element { | |
| 744 | jcid: 0, | |
| 745 | children: Vec::new(), | |
| 746 | content: Vec::new(), | |
| 747 | structure: Vec::new(), | |
| 748 | spaces: Vec::new(), | |
| 749 | child_level: None, | |
| 750 | layout: Layout::default(), | |
| 751 | format: Format::default(), | |
| 752 | created: None, | |
| 753 | modified: None, | |
| 754 | original_author: None, | |
| 755 | latest_author: None, | |
| 756 | media_ids: Vec::new(), | |
| 757 | media_time_ms: None, | |
| 758 | tags: Vec::new(), | |
| 759 | kind, | |
| 760 | extra: Vec::new(), | |
| 761 | } | |
| 762 | } | |
| 763 | ||
| 764 | fn revision() -> Revision<'static> { | |
| 765 | let mut page = element(Kind::Page { | |
| 766 | alternate_title: Some("Fallback".into()), | |
| 767 | level: None, | |
| 768 | width: None, | |
| 769 | height: None, | |
| 770 | margin_origin_x: Some(36.0), | |
| 771 | margin_origin_y: Some(12.0), | |
| 772 | rtl: None, | |
| 773 | }); | |
| 774 | page.children.push(id(2)); | |
| 775 | let mut outline = element(Kind::Outline { | |
| 776 | indents: vec![18.0, 0.0, 36.0], | |
| 777 | }); | |
| 778 | outline.children.push(id(3)); | |
| 779 | outline.child_level = Some(1); | |
| 780 | outline.format.font_size = Some(9.0); | |
| 781 | outline.format.bold = Some(true); | |
| 782 | let mut paragraph = element(Kind::Paragraph { | |
| 783 | lists: Vec::new(), | |
| 784 | paragraph_style: Some(id(5)), | |
| 785 | collapse_state: Some(1), | |
| 786 | }); | |
| 787 | paragraph.content.push(id(4)); | |
| 788 | paragraph.format.bold = Some(false); | |
| 789 | let text = element(Kind::RichText { | |
| 790 | text: "ab".into(), | |
| 791 | runs: vec![TextRun { | |
| 792 | start: 0, | |
| 793 | end: 2, | |
| 794 | format: None, | |
| 795 | extra_set: None, | |
| 796 | }], | |
| 797 | paragraph_style: None, | |
| 798 | boilerplate: false, | |
| 799 | }); | |
| 800 | let mut style = element(Kind::Style { | |
| 801 | name: Some("Body".into()), | |
| 802 | }); | |
| 803 | style.format.font_size = Some(12.0); | |
| 804 | let metadata = element(Kind::Metadata { | |
| 805 | title: Some("Page title".into()), | |
| 806 | level: None, | |
| 807 | }); | |
| 808 | Revision { | |
| 809 | roots: BTreeMap::from([(2, id(6))]), | |
| 810 | nodes: BTreeMap::from([ | |
| 811 | (id(1), page), | |
| 812 | (id(2), outline), | |
| 813 | (id(3), paragraph), | |
| 814 | (id(4), text), | |
| 815 | (id(5), style), | |
| 816 | (id(6), metadata), | |
| 817 | ]), | |
| 818 | } | |
| 819 | } | |
| 820 | ||
| 821 | fn table_revision() -> Revision<'static> { | |
| 822 | let mut source = revision(); | |
| 823 | source.nodes.get_mut(&id(3)).unwrap().content = vec![id(7)]; | |
| 824 | let mut table = element(Kind::Table { | |
| 825 | rows: Some(1), | |
| 826 | columns: Some(2), | |
| 827 | widths: vec![37.11, 99.0], | |
| 828 | locked: vec![false, true], | |
| 829 | borders: Some(false), | |
| 830 | }); | |
| 831 | table.children = vec![id(8)]; | |
| 832 | let mut row = element(Kind::Row); | |
| 833 | row.children = vec![id(9), id(10)]; | |
| 834 | source.nodes.insert(id(7), table); | |
| 835 | source.nodes.insert(id(8), row); | |
| 836 | for (cell, paragraph, text) in [(9, 11, 4), (10, 12, 13)] { | |
| 837 | let mut node = element(Kind::Cell { | |
| 838 | shading: Some(0x00ffff), | |
| 839 | indents: vec![18.0, 0.0, 27.0, 27.0], | |
| 840 | }); | |
| 841 | node.child_level = Some(1); | |
| 842 | node.layout.max_width = Some(268.8); | |
| 843 | node.children = vec![id(paragraph)]; | |
| 844 | if cell == 10 { | |
| 845 | node.format.font_size = Some(13.0); | |
| 846 | } | |
| 847 | source.nodes.insert(id(cell), node); | |
| 848 | let mut node = element(Kind::Paragraph { | |
| 849 | lists: Vec::new(), | |
| 850 | paragraph_style: None, | |
| 851 | collapse_state: None, | |
| 852 | }); | |
| 853 | node.content = vec![id(text)]; | |
| 854 | source.nodes.insert(id(paragraph), node); | |
| 855 | } | |
| 856 | source.nodes.insert( | |
| 857 | id(13), | |
| 858 | element(Kind::RichText { | |
| 859 | text: "cd".into(), | |
| 860 | runs: vec![TextRun { | |
| 861 | start: 0, | |
| 862 | end: 2, | |
| 863 | format: None, | |
| 864 | extra_set: None, | |
| 865 | }], | |
| 866 | paragraph_style: None, | |
| 867 | boilerplate: false, | |
| 868 | }), | |
| 869 | ); | |
| 870 | source | |
| 871 | } | |
| 872 | ||
| 873 | #[test] | |
| 874 | fn table_import_owns_cells_and_inherits_format_across_containers() { | |
| 875 | let page = { | |
| 876 | let source = table_revision(); | |
| 877 | let before = serde_json::to_vec(&source).unwrap(); | |
| 878 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 879 | assert_eq!(serde_json::to_vec(&source).unwrap(), before); | |
| 880 | page | |
| 881 | }; | |
| 882 | let PageObject::Outline(outline) = &page.objects[0] else { | |
| 883 | panic!() | |
| 884 | }; | |
| 885 | let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { | |
| 886 | panic!() | |
| 887 | }; | |
| 888 | assert_eq!(table.id, id(7)); | |
| 889 | assert_eq!(table.borders, Some(false)); | |
| 890 | assert_eq!( | |
| 891 | table.columns, | |
| 892 | [ | |
| 893 | TableColumn { | |
| 894 | width: 37.11, | |
| 895 | locked: false | |
| 896 | }, | |
| 897 | TableColumn { | |
| 898 | width: 99.0, | |
| 899 | locked: true | |
| 900 | } | |
| 901 | ] | |
| 902 | ); | |
| 903 | assert_eq!(table.rows[0].id, id(8)); | |
| 904 | for (index, cell) in table.rows[0].cells.iter().enumerate() { | |
| 905 | assert_eq!(cell.id, id(9 + index as u32)); | |
| 906 | assert_eq!(cell.layout.max_width, Some(268.8)); | |
| 907 | assert_eq!(cell.indents, [18.0, 0.0, 27.0, 27.0]); | |
| 908 | assert_eq!(cell.shading, Some(0x00ffff)); | |
| 909 | let paragraph = &cell.paragraphs[0]; | |
| 910 | assert_eq!(paragraph.parent, None); | |
| 911 | assert_eq!(paragraph.level, 1); | |
| 912 | assert_eq!(paragraph.id, id(11 + index as u32)); | |
| 913 | let text = paragraph.text().unwrap(); | |
| 914 | assert_eq!(text.id, if index == 0 { id(4) } else { id(13) }); | |
| 915 | assert_eq!(text.text.text(), if index == 0 { "ab" } else { "cd" }); | |
| 916 | assert_eq!( | |
| 917 | text.text.spans()[0].format.font_size, | |
| 918 | Some(if index == 0 { 12.0 } else { 13.0 }) | |
| 919 | ); | |
| 920 | assert_eq!(text.text.spans()[0].format.bold, Some(false)); | |
| 921 | } | |
| 922 | } | |
| 923 | ||
| 924 | #[test] | |
| 925 | fn absent_column_locks_use_the_specified_unlocked_default() { | |
| 926 | let mut source = table_revision(); | |
| 927 | let Kind::Table { locked, .. } = &mut source.nodes.get_mut(&id(7)).unwrap().kind else { | |
| 928 | panic!() | |
| 929 | }; | |
| 930 | locked.clear(); | |
| 931 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 932 | let PageObject::Outline(outline) = &page.objects[0] else { | |
| 933 | panic!() | |
| 934 | }; | |
| 935 | let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { | |
| 936 | panic!() | |
| 937 | }; | |
| 938 | assert!(table.columns.iter().all(|column| !column.locked)); | |
| 939 | } | |
| 940 | ||
| 941 | #[test] | |
| 942 | fn table_import_rejects_aliases_cycles_and_inconsistent_dimensions() { | |
| 943 | for case in 0..7 { | |
| 944 | let mut source = table_revision(); | |
| 945 | match case { | |
| 946 | 0 => source.nodes.get_mut(&id(8)).unwrap().children[1] = id(9), | |
| 947 | 1 => source.nodes.get_mut(&id(11)).unwrap().content = vec![id(7)], | |
| 948 | 2 => source.nodes.get_mut(&id(12)).unwrap().content = vec![id(4)], | |
| 949 | 3 => source.nodes.get_mut(&id(3)).unwrap().content.push(id(4)), | |
| 950 | 4 => source.nodes.get_mut(&id(9)).unwrap().children.clear(), | |
| 951 | 5 => { | |
| 952 | let Kind::Table { columns, .. } = | |
| 953 | &mut source.nodes.get_mut(&id(7)).unwrap().kind | |
| 954 | else { | |
| 955 | panic!() | |
| 956 | }; | |
| 957 | *columns = Some(3); | |
| 958 | } | |
| 959 | _ => { | |
| 960 | let Kind::Table { widths, .. } = | |
| 961 | &mut source.nodes.get_mut(&id(7)).unwrap().kind | |
| 962 | else { | |
| 963 | panic!() | |
| 964 | }; | |
| 965 | widths[0] = f32::INFINITY; | |
| 966 | } | |
| 967 | } | |
| 968 | assert!(Page::from_revision(&source, id(1)).is_err(), "case {case}"); | |
| 969 | } | |
| 970 | } | |
| 971 | ||
| 972 | #[test] | |
| 973 | fn nested_tables_import_with_a_bounded_depth() { | |
| 974 | for levels in [1, 62, 65] { | |
| 975 | let mut source = table_revision(); | |
| 976 | let mut paragraph = id(11); | |
| 977 | for depth in 0..levels { | |
| 978 | let base = 20 + depth * 4; | |
| 979 | source.nodes.get_mut(&paragraph).unwrap().content = vec![id(base)]; | |
| 980 | let mut table = element(Kind::Table { | |
| 981 | rows: Some(1), | |
| 982 | columns: Some(1), | |
| 983 | widths: vec![72.0], | |
| 984 | locked: vec![true], | |
| 985 | borders: Some(true), | |
| 986 | }); | |
| 987 | table.children = vec![id(base + 1)]; | |
| 988 | let mut row = element(Kind::Row); | |
| 989 | row.children = vec![id(base + 2)]; | |
| 990 | let mut cell = element(Kind::Cell { | |
| 991 | shading: None, | |
| 992 | indents: vec![18.0, 0.0, 27.0, 27.0], | |
| 993 | }); | |
| 994 | cell.child_level = Some(1); | |
| 995 | cell.children = vec![id(base + 3)]; | |
| 996 | let mut child = element(Kind::Paragraph { | |
| 997 | lists: Vec::new(), | |
| 998 | paragraph_style: None, | |
| 999 | collapse_state: None, | |
| 1000 | }); | |
| 1001 | child.content = vec![id(4)]; | |
| 1002 | source.nodes.extend([ | |
| 1003 | (id(base), table), | |
| 1004 | (id(base + 1), row), | |
| 1005 | (id(base + 2), cell), | |
| 1006 | (id(base + 3), child), | |
| 1007 | ]); | |
| 1008 | paragraph = id(base + 3); | |
| 1009 | } | |
| 1010 | assert_eq!(Page::from_revision(&source, id(1)).is_ok(), levels < 64); | |
| 1011 | } | |
| 1012 | } | |
| 1013 | ||
| 1014 | #[test] | |
| 1015 | fn owns_page_content_and_resolves_style_before_ancestor_defaults() { | |
| 1016 | let page = { | |
| 1017 | let mut source = revision(); | |
| 1018 | source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag { | |
| 1019 | definition: Some(id(7)), | |
| 1020 | action_type: None, | |
| 1021 | status: 0, | |
| 1022 | created: Some(123), | |
| 1023 | completed: None, | |
| 1024 | start: None, | |
| 1025 | due: None, | |
| 1026 | task_id: None, | |
| 1027 | extra_set: 0, | |
| 1028 | }); | |
| 1029 | source.nodes.insert( | |
| 1030 | id(7), | |
| 1031 | element(Kind::TagDefinition { | |
| 1032 | label: Some("To Do".into()), | |
| 1033 | action_type: Some(0), | |
| 1034 | shape: Some(3), | |
| 1035 | color: None, | |
| 1036 | highlight: None, | |
| 1037 | }), | |
| 1038 | ); | |
| 1039 | let before = serde_json::to_vec(&source).unwrap(); | |
| 1040 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 1041 | assert_eq!(before, serde_json::to_vec(&source).unwrap()); | |
| 1042 | page | |
| 1043 | }; | |
| 1044 | assert_eq!(page.title, "Page title"); | |
| 1045 | assert_eq!(page.margin_origin, [36.0, 12.0]); | |
| 1046 | let PageObject::Outline(outline) = &page.objects[0] else { | |
| 1047 | panic!() | |
| 1048 | }; | |
| 1049 | assert_eq!(outline.paragraphs.len(), 1); | |
| 1050 | let paragraph = &outline.paragraphs[0]; | |
| 1051 | assert!(paragraph.collapsed); | |
| 1052 | assert_eq!(paragraph.level, 1); | |
| 1053 | assert_eq!(paragraph.parent, None); | |
| 1054 | assert_eq!(paragraph.style, Some(id(5))); | |
| 1055 | assert!(matches!(&page.definitions[&id(5)].kind, | |
| 1056 | Kind::Style { name: Some(name) } if name == "Body")); | |
| 1057 | assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0)); | |
| 1058 | let text = &paragraph.text().unwrap().text; | |
| 1059 | assert_eq!(text.text(), "ab"); | |
| 1060 | assert_eq!(text.spans()[0].format.font_size, Some(12.0)); | |
| 1061 | assert_eq!(text.spans()[0].format.bold, Some(false)); | |
| 1062 | assert_eq!(paragraph.text().unwrap().tags[0].created, Some(123)); | |
| 1063 | assert!(matches!(&page.definitions[&id(7)].kind, | |
| 1064 | Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do")); | |
| 1065 | } | |
| 1066 | ||
| 1067 | #[test] | |
| 1068 | fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() { | |
| 1069 | use crate::document::{Field, FieldValue}; | |
| 1070 | let mut source = revision(); | |
| 1071 | source.nodes.get_mut(&id(1)).unwrap().created = Some(7); | |
| 1072 | let bytes = 134_333_468_649_123_456_u64.to_le_bytes(); | |
| 1073 | source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field { | |
| 1074 | id: 0x18001c65, | |
| 1075 | value: FieldValue::Bytes(&bytes), | |
| 1076 | }]]; | |
| 1077 | source.nodes.get_mut(&id(1)).unwrap().children.clear(); | |
| 1078 | source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); | |
| 1079 | let mut title = element(Kind::Title); | |
| 1080 | title.children.push(id(2)); | |
| 1081 | source.nodes.insert(id(7), title); | |
| 1082 | source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![Field { | |
| 1083 | id: 0x88001cb5, | |
| 1084 | value: FieldValue::NoData, | |
| 1085 | }]]; | |
| 1086 | source.nodes.get_mut(&id(4)).unwrap().extra = vec![vec![Field { | |
| 1087 | id: 0x88001c87, | |
| 1088 | value: FieldValue::NoData, | |
| 1089 | }]]; | |
| 1090 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 1091 | assert_eq!(page.created, Some(134_333_468_649_123_456)); | |
| 1092 | let PageObject::Title(title) = &page.objects[0] else { | |
| 1093 | panic!() | |
| 1094 | }; | |
| 1095 | assert_eq!(title.date, Some(id(2))); | |
| 1096 | assert_eq!( | |
| 1097 | title.outlines[0].paragraphs[0].text().unwrap().date_field, | |
| 1098 | Some(DateField::Time) | |
| 1099 | ); | |
| 1100 | source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]); | |
| 1101 | assert!(Page::from_revision(&source, id(1)).is_err()); | |
| 1102 | source.nodes.get_mut(&id(6)).unwrap().extra.clear(); | |
| 1103 | assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None); | |
| 1104 | } | |
| 1105 | ||
| 1106 | #[test] | |
| 1107 | fn title_role_and_minimum_width_come_from_root_properties() { | |
| 1108 | use crate::document::{Field, FieldValue}; | |
| 1109 | let mut source = revision(); | |
| 1110 | source.nodes.get_mut(&id(1)).unwrap().children.clear(); | |
| 1111 | source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); | |
| 1112 | let mut title = element(Kind::Title); | |
| 1113 | title.children.push(id(2)); | |
| 1114 | source.nodes.insert(id(7), title); | |
| 1115 | source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![ | |
| 1116 | Field { | |
| 1117 | id: 0x88001cb4, | |
| 1118 | value: FieldValue::NoData, | |
| 1119 | }, | |
| 1120 | Field { | |
| 1121 | id: 0x14001cec, | |
| 1122 | value: FieldValue::Bytes(&[0, 0, 0x90, 0x40]), | |
| 1123 | }, | |
| 1124 | ]]; | |
| 1125 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 1126 | let PageObject::Title(title) = &page.objects[0] else { | |
| 1127 | panic!() | |
| 1128 | }; | |
| 1129 | assert!(title.outlines[0].title); | |
| 1130 | assert_eq!(title.outlines[0].min_width, Some(162.0)); | |
| 1131 | source.nodes.get_mut(&id(2)).unwrap().extra[0][1].value = | |
| 1132 | FieldValue::Bytes(&[0, 0, 0x80, 0x7f]); | |
| 1133 | assert!(Page::from_revision(&source, id(1)).is_err()); | |
| 1134 | source.nodes.get_mut(&id(2)).unwrap().extra = vec![ | |
| 1135 | vec![Field { | |
| 1136 | id: 0x08001cb4, | |
| 1137 | value: FieldValue::NoData, | |
| 1138 | }], | |
| 1139 | vec![Field { | |
| 1140 | id: 0x88001cb4, | |
| 1141 | value: FieldValue::NoData, | |
| 1142 | }], | |
| 1143 | ]; | |
| 1144 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 1145 | let PageObject::Title(title) = &page.objects[0] else { | |
| 1146 | panic!() | |
| 1147 | }; | |
| 1148 | assert!(!title.outlines[0].title); | |
| 1149 | assert_eq!(title.outlines[0].min_width, None); | |
| 1150 | } | |
| 1151 | ||
| 1152 | #[test] | |
| 1153 | fn preserves_paint_order_nested_parents_and_owned_image_payloads() { | |
| 1154 | let bytes = vec![1, 2, 3, 4]; | |
| 1155 | let mut source = revision(); | |
| 1156 | let image = element(Kind::Image { | |
| 1157 | container: Some(id(8)), | |
| 1158 | filename: None, | |
| 1159 | alt: Some("Image".into()), | |
| 1160 | picture_width: None, | |
| 1161 | picture_height: None, | |
| 1162 | background: Some(true), | |
| 1163 | printout: None, | |
| 1164 | link: None, | |
| 1165 | }); | |
| 1166 | let file = element(Kind::File { | |
| 1167 | reference: crate::FileDataReference::Internal([0; 16]), | |
| 1168 | extension: "png".into(), | |
| 1169 | payload: Some(bytes.as_slice()), | |
| 1170 | }); | |
| 1171 | let mut title = element(Kind::Title); | |
| 1172 | title.layout.x = Some(12.0); | |
| 1173 | title.layout.y = Some(24.0); | |
| 1174 | title.children.push(id(10)); | |
| 1175 | let title_outline = element(Kind::Outline { | |
| 1176 | indents: Vec::new(), | |
| 1177 | }); | |
| 1178 | let mut group = element(Kind::OutlineGroup); | |
| 1179 | group.children.push(id(12)); | |
| 1180 | group.child_level = Some(1); | |
| 1181 | let mut child = element(Kind::Paragraph { | |
| 1182 | lists: Vec::new(), | |
| 1183 | paragraph_style: None, | |
| 1184 | collapse_state: None, | |
| 1185 | }); | |
| 1186 | child.content.push(id(13)); | |
| 1187 | source.nodes.insert( | |
| 1188 | id(13), | |
| 1189 | element(Kind::RichText { | |
| 1190 | text: "".into(), | |
| 1191 | runs: Vec::new(), | |
| 1192 | paragraph_style: None, | |
| 1193 | boilerplate: false, | |
| 1194 | }), | |
| 1195 | ); | |
| 1196 | source.nodes.extend([ | |
| 1197 | (id(7), image), | |
| 1198 | (id(8), file), | |
| 1199 | (id(9), title), | |
| 1200 | (id(10), title_outline), | |
| 1201 | (id(11), group), | |
| 1202 | (id(12), child), | |
| 1203 | ]); | |
| 1204 | source | |
| 1205 | .nodes | |
| 1206 | .get_mut(&id(1)) | |
| 1207 | .unwrap() | |
| 1208 | .children | |
| 1209 | .insert(0, id(7)); | |
| 1210 | source.nodes.get_mut(&id(1)).unwrap().structure.push(id(9)); | |
| 1211 | source.nodes.get_mut(&id(3)).unwrap().children.push(id(11)); | |
| 1212 | source.nodes.get_mut(&id(3)).unwrap().child_level = Some(1); | |
| 1213 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 1214 | drop(source); | |
| 1215 | drop(bytes); | |
| 1216 | assert_eq!(page.objects.len(), 3); | |
| 1217 | let PageObject::Image(image) = &page.objects[0] else { | |
| 1218 | panic!() | |
| 1219 | }; | |
| 1220 | assert_eq!(image.bytes.as_deref(), Some([1, 2, 3, 4].as_slice())); | |
| 1221 | let PageObject::Outline(outline) = &page.objects[1] else { | |
| 1222 | panic!() | |
| 1223 | }; | |
| 1224 | assert_eq!(outline.paragraphs[1].id, id(12)); | |
| 1225 | assert_eq!(outline.paragraphs[1].parent, Some(id(3))); | |
| 1226 | assert_eq!(outline.paragraphs[1].level, 3); | |
| 1227 | let PageObject::Title(title) = &page.objects[2] else { | |
| 1228 | panic!() | |
| 1229 | }; | |
| 1230 | assert_eq!(title.id, id(9)); | |
| 1231 | assert_eq!([title.layout.x, title.layout.y], [Some(12.0), Some(24.0)]); | |
| 1232 | assert_eq!(title.outlines[0].id, id(10)); | |
| 1233 | assert_eq!(title.outlines[0].layout.x, None); | |
| 1234 | } | |
| 1235 | ||
| 1236 | #[test] | |
| 1237 | fn rejects_title_children_that_are_not_outlines() { | |
| 1238 | let mut source = revision(); | |
| 1239 | let mut title = element(Kind::Title); | |
| 1240 | title.children.push(id(8)); | |
| 1241 | source.nodes.insert(id(7), title); | |
| 1242 | source.nodes.insert(id(8), element(Kind::Title)); | |
| 1243 | source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); | |
| 1244 | assert_eq!( | |
| 1245 | Page::from_revision(&source, id(1)).err().unwrap().message, | |
| 1246 | "Canvas title child is not an outline" | |
| 1247 | ); | |
| 1248 | } | |
| 1249 | ||
| 1250 | #[test] | |
| 1251 | fn validates_empty_text_styles_missing_references_and_cycles() { | |
| 1252 | let mut source = revision(); | |
| 1253 | source.nodes.get_mut(&id(4)).unwrap().kind = Kind::RichText { | |
| 1254 | text: String::new(), | |
| 1255 | runs: Vec::new(), | |
| 1256 | paragraph_style: Some(id(5)), | |
| 1257 | boilerplate: false, | |
| 1258 | }; | |
| 1259 | source.nodes.get_mut(&id(3)).unwrap().format.font_size = Some(10.0); | |
| 1260 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 1261 | let PageObject::Outline(outline) = &page.objects[0] else { | |
| 1262 | panic!() | |
| 1263 | }; | |
| 1264 | assert_eq!( | |
| 1265 | outline.paragraphs[0].text().unwrap().text.spans()[0] | |
| 1266 | .format | |
| 1267 | .font_size, | |
| 1268 | Some(12.0) | |
| 1269 | ); | |
| 1270 | source.nodes.get_mut(&id(3)).unwrap().children.push(id(2)); | |
| 1271 | assert!(Page::from_revision(&source, id(1)).is_err()); | |
| 1272 | source.nodes.get_mut(&id(3)).unwrap().children.clear(); | |
| 1273 | source.nodes.remove(&id(5)); | |
| 1274 | assert!(Page::from_revision(&source, id(1)).is_err()); | |
| 1275 | } | |
| 1276 | } |
crates/onestore/src/page/text.rs created+571| ... | ... | @@ -0,0 +1,571 @@ |
| 1 | use crate::{ExGuid, document::Format}; | |
| 2 | use std::{fmt, ops::Range}; | |
| 3 | ||
| 4 | #[derive(Clone, Debug, PartialEq)] | |
| 5 | pub struct Span { | |
| 6 | /// Exclusive UTF-8 boundary; the start is the preceding span's end. | |
| 7 | pub end: usize, | |
| 8 | pub format: Format, | |
| 9 | } | |
| 10 | ||
| 11 | #[derive(Clone, Debug, PartialEq)] | |
| 12 | /// Editable text styles are coalesced independently of serialized run boundaries. | |
| 13 | pub struct Paragraph { | |
| 14 | text: String, | |
| 15 | spans: Vec<Span>, | |
| 16 | } | |
| 17 | ||
| 18 | /// Which side of a hidden field a visible boundary maps to. | |
| 19 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 20 | pub enum Affinity { | |
| 21 | Upstream, | |
| 22 | Downstream, | |
| 23 | } | |
| 24 | ||
| 25 | #[derive(Clone)] | |
| 26 | pub struct TextProjection { | |
| 27 | text: Paragraph, | |
| 28 | spans: Vec<ProjectedSpan>, | |
| 29 | source_len: u32, | |
| 30 | } | |
| 31 | ||
| 32 | #[derive(Clone)] | |
| 33 | struct ProjectedSpan { | |
| 34 | visible: Range<u32>, | |
| 35 | source_start: u32, | |
| 36 | } | |
| 37 | ||
| 38 | impl TextProjection { | |
| 39 | pub fn text(&self) -> &Paragraph { | |
| 40 | &self.text | |
| 41 | } | |
| 42 | ||
| 43 | /// Maps visible UTF-8 boundaries to downstream source UTF-16 positions in one pass. | |
| 44 | pub fn source_boundaries(&self) -> impl Iterator<Item = (usize, u32)> + '_ { | |
| 45 | let mut spans = self.spans.iter().peekable(); | |
| 46 | let mut visible = 0; | |
| 47 | self.text | |
| 48 | .text | |
| 49 | .char_indices() | |
| 50 | .map(move |(byte, character)| { | |
| 51 | while spans.peek().is_some_and(|span| span.visible.end <= visible) { | |
| 52 | spans.next(); | |
| 53 | } | |
| 54 | let span = spans.peek().unwrap(); | |
| 55 | let source = span.source_start + (visible - span.visible.start); | |
| 56 | visible += character.len_utf16() as u32; | |
| 57 | (byte, source) | |
| 58 | }) | |
| 59 | .chain(std::iter::once((self.text.text.len(), self.source_len))) | |
| 60 | } | |
| 61 | ||
| 62 | pub fn source_offset(&self, visible: u32, affinity: Affinity) -> Result<u32, EditError> { | |
| 63 | self.text.byte_offset(visible)?; | |
| 64 | Ok(match affinity { | |
| 65 | Affinity::Downstream => self | |
| 66 | .spans | |
| 67 | .iter() | |
| 68 | .find(|span| span.visible.end > visible) | |
| 69 | .map(|span| span.source_start + (visible - span.visible.start)) | |
| 70 | .unwrap_or(self.source_len), | |
| 71 | Affinity::Upstream => self | |
| 72 | .spans | |
| 73 | .iter() | |
| 74 | .rev() | |
| 75 | .find(|span| span.visible.start < visible) | |
| 76 | .map(|span| span.source_start + (visible - span.visible.start)) | |
| 77 | .unwrap_or(0), | |
| 78 | }) | |
| 79 | } | |
| 80 | ||
| 81 | /// Hidden source positions collapse to their visible boundary. | |
| 82 | pub fn visible_offset(&self, source: u32) -> Result<u32, EditError> { | |
| 83 | if source > self.source_len { | |
| 84 | return Err(EditError::InvalidRange); | |
| 85 | } | |
| 86 | for span in &self.spans { | |
| 87 | if source < span.source_start { | |
| 88 | return Ok(span.visible.start); | |
| 89 | } | |
| 90 | let length = span.visible.end - span.visible.start; | |
| 91 | if source < span.source_start + length { | |
| 92 | let visible = span.visible.start + source - span.source_start; | |
| 93 | self.text.byte_offset(visible)?; | |
| 94 | return Ok(visible); | |
| 95 | } | |
| 96 | } | |
| 97 | self.text.utf16_offset(self.text.text.len()) | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | #[derive(Clone, Debug, PartialEq)] | |
| 102 | pub struct Edit { | |
| 103 | pub range: Range<u32>, | |
| 104 | pub replacement: Paragraph, | |
| 105 | } | |
| 106 | ||
| 107 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 108 | pub enum EditError { | |
| 109 | InvalidRange, | |
| 110 | TextTooLong, | |
| 111 | InvalidStructure, | |
| 112 | UnsupportedContent, | |
| 113 | /// The system random source failed while allocating a new identity. | |
| 114 | Identity, | |
| 115 | } | |
| 116 | ||
| 117 | impl fmt::Display for EditError { | |
| 118 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 119 | f.write_str(match self { | |
| 120 | Self::Identity => "System random source failed", | |
| 121 | Self::UnsupportedContent => { | |
| 122 | "This paragraph contains content the text editor cannot edit" | |
| 123 | } | |
| 124 | Self::InvalidRange => "Text range is outside the paragraph or splits a surrogate pair", | |
| 125 | Self::TextTooLong => "Text exceeds the UTF-16 offset range", | |
| 126 | Self::InvalidStructure => { | |
| 127 | "This outline has duplicate objects or broken paragraph links" | |
| 128 | } | |
| 129 | }) | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | impl std::error::Error for EditError {} | |
| 134 | ||
| 135 | /// A fresh random identity for a new paragraph or text object. | |
| 136 | pub fn new_id() -> Result<ExGuid, EditError> { | |
| 137 | Ok(ExGuid { | |
| 138 | guid: crate::write::fresh_guid().map_err(|_| EditError::Identity)?, | |
| 139 | n: 0, | |
| 140 | }) | |
| 141 | } | |
| 142 | ||
| 143 | impl Paragraph { | |
| 144 | pub fn project(&self) -> Result<TextProjection, EditError> { | |
| 145 | let mut runs = Vec::new(); | |
| 146 | let mut spans: Vec<ProjectedSpan> = Vec::new(); | |
| 147 | let mut byte = 0; | |
| 148 | let mut source = 0_u32; | |
| 149 | let mut visible = 0_u32; | |
| 150 | for span in &self.spans { | |
| 151 | let fragment = &self.text[byte..span.end]; | |
| 152 | let length: u32 = fragment | |
| 153 | .encode_utf16() | |
| 154 | .count() | |
| 155 | .try_into() | |
| 156 | .map_err(|_| EditError::TextTooLong)?; | |
| 157 | let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?; | |
| 158 | if span.format.hidden != Some(true) { | |
| 159 | runs.push((fragment.replace('\u{000b}', "\n"), span.format.clone())); | |
| 160 | if length > 0 { | |
| 161 | if let Some(last) = spans.last_mut() | |
| 162 | && last.source_start + (last.visible.end - last.visible.start) == source | |
| 163 | { | |
| 164 | last.visible.end += length; | |
| 165 | } else { | |
| 166 | spans.push(ProjectedSpan { | |
| 167 | visible: visible..visible + length, | |
| 168 | source_start: source, | |
| 169 | }); | |
| 170 | } | |
| 171 | visible += length; | |
| 172 | } | |
| 173 | } | |
| 174 | byte = span.end; | |
| 175 | source = source_end; | |
| 176 | } | |
| 177 | if runs.is_empty() { | |
| 178 | let mut format = self.spans[0].format.clone(); | |
| 179 | format.hidden = Some(false); | |
| 180 | runs.push((String::new(), format)); | |
| 181 | } | |
| 182 | Ok(TextProjection { | |
| 183 | text: Self::from_runs(runs), | |
| 184 | spans, | |
| 185 | source_len: source, | |
| 186 | }) | |
| 187 | } | |
| 188 | ||
| 189 | pub fn new(text: String, format: Format) -> Self { | |
| 190 | let end = text.len(); | |
| 191 | Self { | |
| 192 | text, | |
| 193 | spans: vec![Span { end, format }], | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | pub fn from_runs(runs: impl IntoIterator<Item = (String, Format)>) -> Self { | |
| 198 | let mut text = String::new(); | |
| 199 | let mut spans: Vec<Span> = Vec::new(); | |
| 200 | for (fragment, format) in runs { | |
| 201 | text.push_str(&fragment); | |
| 202 | if let Some(last) = spans.last_mut() { | |
| 203 | if last.format == format { | |
| 204 | last.end = text.len(); | |
| 205 | continue; | |
| 206 | } | |
| 207 | if fragment.is_empty() && !text.is_empty() { | |
| 208 | continue; | |
| 209 | } | |
| 210 | if last.end == 0 { | |
| 211 | spans.clear(); | |
| 212 | } | |
| 213 | } | |
| 214 | spans.push(Span { | |
| 215 | end: text.len(), | |
| 216 | format, | |
| 217 | }); | |
| 218 | } | |
| 219 | if spans.is_empty() { | |
| 220 | spans.push(Span { | |
| 221 | end: 0, | |
| 222 | format: Format::default(), | |
| 223 | }); | |
| 224 | } | |
| 225 | Self { text, spans } | |
| 226 | } | |
| 227 | ||
| 228 | pub fn text(&self) -> &str { | |
| 229 | &self.text | |
| 230 | } | |
| 231 | ||
| 232 | pub fn spans(&self) -> &[Span] { | |
| 233 | &self.spans | |
| 234 | } | |
| 235 | ||
| 236 | pub fn byte_offset(&self, utf16: u32) -> Result<usize, EditError> { | |
| 237 | let mut units = 0_u64; | |
| 238 | for (byte, character) in self.text.char_indices() { | |
| 239 | if units == u64::from(utf16) { | |
| 240 | return Ok(byte); | |
| 241 | } | |
| 242 | units += character.len_utf16() as u64; | |
| 243 | if units > u64::from(utf16) { | |
| 244 | return Err(EditError::InvalidRange); | |
| 245 | } | |
| 246 | } | |
| 247 | if units == u64::from(utf16) { | |
| 248 | Ok(self.text.len()) | |
| 249 | } else { | |
| 250 | Err(EditError::InvalidRange) | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | pub fn utf16_offset(&self, byte: usize) -> Result<u32, EditError> { | |
| 255 | self.text | |
| 256 | .get(..byte) | |
| 257 | .ok_or(EditError::InvalidRange)? | |
| 258 | .encode_utf16() | |
| 259 | .count() | |
| 260 | .try_into() | |
| 261 | .map_err(|_| EditError::TextTooLong) | |
| 262 | } | |
| 263 | ||
| 264 | pub fn format_at(&self, utf16: u32) -> Result<&Format, EditError> { | |
| 265 | let byte = self.byte_offset(utf16)?; | |
| 266 | let index = self.spans.partition_point(|span| span.end < byte); | |
| 267 | Ok(&self.spans[index].format) | |
| 268 | } | |
| 269 | ||
| 270 | pub fn slice(&self, range: Range<u32>) -> Result<Self, EditError> { | |
| 271 | if range.start > range.end { | |
| 272 | return Err(EditError::InvalidRange); | |
| 273 | } | |
| 274 | let start = self.byte_offset(range.start)?; | |
| 275 | let end = self.byte_offset(range.end)?; | |
| 276 | if start == end { | |
| 277 | return Ok(Self::new( | |
| 278 | String::new(), | |
| 279 | self.format_at(range.start)?.clone(), | |
| 280 | )); | |
| 281 | } | |
| 282 | let mut previous = 0; | |
| 283 | let mut spans = Vec::new(); | |
| 284 | for span in &self.spans { | |
| 285 | if previous < end && span.end > start { | |
| 286 | spans.push(Span { | |
| 287 | end: span.end.min(end) - start, | |
| 288 | format: span.format.clone(), | |
| 289 | }); | |
| 290 | } | |
| 291 | previous = span.end; | |
| 292 | } | |
| 293 | Ok(Self { | |
| 294 | text: self.text[start..end].to_owned(), | |
| 295 | spans, | |
| 296 | }) | |
| 297 | } | |
| 298 | ||
| 299 | /// Returns the inverse edit; applying that inverse returns a redo operation. | |
| 300 | pub fn apply(&mut self, edit: Edit) -> Result<Edit, EditError> { | |
| 301 | let end = self.utf16_offset(self.text.len())?; | |
| 302 | let removed = self.slice(edit.range.clone())?; | |
| 303 | let inserted = edit.replacement.utf16_offset(edit.replacement.text.len())?; | |
| 304 | let new_end = edit | |
| 305 | .range | |
| 306 | .start | |
| 307 | .checked_add(inserted) | |
| 308 | .ok_or(EditError::TextTooLong)?; | |
| 309 | let resulting_len = end | |
| 310 | .checked_sub(edit.range.end - edit.range.start) | |
| 311 | .and_then(|n| n.checked_add(inserted)) | |
| 312 | .ok_or(EditError::TextTooLong)?; | |
| 313 | if resulting_len == 0 { | |
| 314 | *self = edit.replacement; | |
| 315 | return Ok(Edit { | |
| 316 | range: edit.range.start..new_end, | |
| 317 | replacement: removed, | |
| 318 | }); | |
| 319 | } | |
| 320 | let prefix = self.slice(0..edit.range.start)?; | |
| 321 | let suffix = self.slice(edit.range.end..end)?; | |
| 322 | let mut runs = Vec::new(); | |
| 323 | for part in [prefix, edit.replacement, suffix] { | |
| 324 | let mut start = 0; | |
| 325 | for span in part.spans { | |
| 326 | if span.end > start { | |
| 327 | runs.push((part.text[start..span.end].to_owned(), span.format)); | |
| 328 | } | |
| 329 | start = span.end; | |
| 330 | } | |
| 331 | } | |
| 332 | *self = Self::from_runs(runs); | |
| 333 | Ok(Edit { | |
| 334 | range: edit.range.start..new_end, | |
| 335 | replacement: removed, | |
| 336 | }) | |
| 337 | } | |
| 338 | ||
| 339 | pub fn append(&mut self, other: Self) -> Result<Edit, EditError> { | |
| 340 | let end = self.utf16_offset(self.text.len())?; | |
| 341 | self.apply(Edit { | |
| 342 | range: end..end, | |
| 343 | replacement: other, | |
| 344 | }) | |
| 345 | } | |
| 346 | } | |
| 347 | ||
| 348 | #[cfg(test)] | |
| 349 | mod tests { | |
| 350 | use super::*; | |
| 351 | ||
| 352 | fn regular() -> Format { | |
| 353 | Format { | |
| 354 | font: Some("Arial".into()), | |
| 355 | font_size: Some(11.0), | |
| 356 | ..Format::default() | |
| 357 | } | |
| 358 | } | |
| 359 | ||
| 360 | fn bold() -> Format { | |
| 361 | Format { | |
| 362 | bold: Some(true), | |
| 363 | ..regular() | |
| 364 | } | |
| 365 | } | |
| 366 | #[test] | |
| 367 | fn soft_breaks_keep_source_offsets_and_formatting() { | |
| 368 | let source = Paragraph::from_runs([("A\u{000b}".into(), regular()), ("🌳".into(), bold())]); | |
| 369 | let projection = source.project().unwrap(); | |
| 370 | assert_eq!(source.text(), "A\u{000b}🌳"); | |
| 371 | assert_eq!(projection.text().text(), "A\n🌳"); | |
| 372 | assert_eq!(projection.text().spans(), source.spans()); | |
| 373 | assert_eq!( | |
| 374 | projection.source_boundaries().collect::<Vec<_>>(), | |
| 375 | [(0, 0), (1, 1), (2, 2), (6, 4)] | |
| 376 | ); | |
| 377 | for offset in [0, 1, 2, 4] { | |
| 378 | assert_eq!(projection.visible_offset(offset), Ok(offset)); | |
| 379 | assert_eq!( | |
| 380 | projection.source_offset(offset, Affinity::Downstream), | |
| 381 | Ok(offset) | |
| 382 | ); | |
| 383 | } | |
| 384 | assert!(projection.visible_offset(3).is_err()); | |
| 385 | } | |
| 386 | ||
| 387 | #[test] | |
| 388 | fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() { | |
| 389 | let hidden = Format { | |
| 390 | hidden: Some(true), | |
| 391 | ..regular() | |
| 392 | }; | |
| 393 | let source = Paragraph::from_runs([ | |
| 394 | ("A".into(), regular()), | |
| 395 | ("🌳".into(), hidden.clone()), | |
| 396 | ("e\u{301}".into(), bold()), | |
| 397 | ("X".into(), hidden), | |
| 398 | ]); | |
| 399 | let original = source.clone(); | |
| 400 | let projection = source.project().unwrap(); | |
| 401 | assert_eq!(projection.text().text(), "Ae\u{301}"); | |
| 402 | assert_eq!( | |
| 403 | projection.source_boundaries().collect::<Vec<_>>(), | |
| 404 | [(0, 0), (1, 3), (2, 4), (4, 6)] | |
| 405 | ); | |
| 406 | for (visible, upstream, downstream) in [(0, 0, 0), (1, 1, 3), (2, 4, 4), (3, 5, 6)] { | |
| 407 | assert_eq!( | |
| 408 | projection.source_offset(visible, Affinity::Upstream), | |
| 409 | Ok(upstream) | |
| 410 | ); | |
| 411 | assert_eq!( | |
| 412 | projection.source_offset(visible, Affinity::Downstream), | |
| 413 | Ok(downstream) | |
| 414 | ); | |
| 415 | } | |
| 416 | for (source, visible) in [(0, 0), (1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3)] { | |
| 417 | assert_eq!(projection.visible_offset(source), Ok(visible)); | |
| 418 | } | |
| 419 | assert_eq!(source, original); | |
| 420 | } | |
| 421 | ||
| 422 | #[test] | |
| 423 | fn projection_validates_visible_surrogates_and_preserves_empty_field_boundaries() { | |
| 424 | let visible = Paragraph::new("a🌳z".into(), regular()).project().unwrap(); | |
| 425 | assert_eq!( | |
| 426 | visible.source_offset(2, Affinity::Downstream), | |
| 427 | Err(EditError::InvalidRange) | |
| 428 | ); | |
| 429 | assert_eq!(visible.visible_offset(2), Err(EditError::InvalidRange)); | |
| 430 | let hidden = Paragraph::new( | |
| 431 | "🌳".into(), | |
| 432 | Format { | |
| 433 | hidden: Some(true), | |
| 434 | ..regular() | |
| 435 | }, | |
| 436 | ) | |
| 437 | .project() | |
| 438 | .unwrap(); | |
| 439 | assert!(hidden.text().text().is_empty()); | |
| 440 | assert_eq!(hidden.source_boundaries().collect::<Vec<_>>(), [(0, 2)]); | |
| 441 | assert_eq!( | |
| 442 | visible.source_boundaries().collect::<Vec<_>>(), | |
| 443 | [(0, 0), (1, 1), (5, 3), (6, 4)] | |
| 444 | ); | |
| 445 | assert_eq!(hidden.source_offset(0, Affinity::Upstream), Ok(0)); | |
| 446 | assert_eq!(hidden.source_offset(0, Affinity::Downstream), Ok(2)); | |
| 447 | assert_eq!(hidden.visible_offset(1), Ok(0)); | |
| 448 | assert_eq!(hidden.visible_offset(3), Err(EditError::InvalidRange)); | |
| 449 | } | |
| 450 | ||
| 451 | #[test] | |
| 452 | fn offsets_distinguish_bytes_utf16_and_scalars() { | |
| 453 | let text = Paragraph::new("a🌳e\u{301}".into(), regular()); | |
| 454 | for (utf16, byte) in [(0, 0), (1, 1), (3, 5), (4, 6), (5, 8)] { | |
| 455 | assert_eq!(text.byte_offset(utf16), Ok(byte)); | |
| 456 | assert_eq!(text.utf16_offset(byte), Ok(utf16)); | |
| 457 | } | |
| 458 | assert_eq!(text.byte_offset(2), Err(EditError::InvalidRange)); | |
| 459 | assert_eq!(text.utf16_offset(2), Err(EditError::InvalidRange)); | |
| 460 | assert_eq!(text.byte_offset(6), Err(EditError::InvalidRange)); | |
| 461 | assert_eq!(text.utf16_offset(9), Err(EditError::InvalidRange)); | |
| 462 | } | |
| 463 | ||
| 464 | #[test] | |
| 465 | fn replacement_preserves_styles_outside_selection() { | |
| 466 | let mut text = Paragraph::from_runs([ | |
| 467 | ("plain ".into(), regular()), | |
| 468 | ("bold".into(), bold()), | |
| 469 | (" end".into(), regular()), | |
| 470 | ]); | |
| 471 | let original = text.clone(); | |
| 472 | let edit = Edit { | |
| 473 | range: 4..8, | |
| 474 | replacement: Paragraph::new("🌳".into(), bold()), | |
| 475 | }; | |
| 476 | let undo = text.apply(edit).unwrap(); | |
| 477 | assert_eq!(text.text(), "plai🌳ld end"); | |
| 478 | assert_eq!( | |
| 479 | text.spans() | |
| 480 | .iter() | |
| 481 | .map(|s| (s.end, s.format.bold)) | |
| 482 | .collect::<Vec<_>>(), | |
| 483 | [(4, None), (10, Some(true)), (14, None)] | |
| 484 | ); | |
| 485 | let after = text.clone(); | |
| 486 | let redo = text.apply(undo).unwrap(); | |
| 487 | assert_eq!(text, original); | |
| 488 | text.apply(redo).unwrap(); | |
| 489 | assert_eq!(text, after); | |
| 490 | } | |
| 491 | ||
| 492 | #[test] | |
| 493 | fn invalid_edits_leave_text_and_styles_unchanged() { | |
| 494 | let original = Paragraph::new("a🌳b".into(), regular()); | |
| 495 | for (start, end) in [(2, 3), (0, 2), (3, 2), (0, 5)] { | |
| 496 | let mut text = original.clone(); | |
| 497 | assert_eq!( | |
| 498 | text.apply(Edit { | |
| 499 | range: start..end, | |
| 500 | replacement: Paragraph::new("x".into(), bold()) | |
| 501 | }), | |
| 502 | Err(EditError::InvalidRange) | |
| 503 | ); | |
| 504 | assert_eq!(text, original); | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | #[test] | |
| 509 | fn undo_restores_empty_paragraph_format() { | |
| 510 | let mut text = Paragraph::new(String::new(), regular()); | |
| 511 | let original = text.clone(); | |
| 512 | let undo = text | |
| 513 | .apply(Edit { | |
| 514 | range: 0..0, | |
| 515 | replacement: Paragraph::new("bold".into(), bold()), | |
| 516 | }) | |
| 517 | .unwrap(); | |
| 518 | text.apply(undo).unwrap(); | |
| 519 | assert_eq!(text, original); | |
| 520 | } | |
| 521 | ||
| 522 | #[test] | |
| 523 | fn every_scalar_range_round_trips_styled_unicode_edits() { | |
| 524 | let original = Paragraph::from_runs([ | |
| 525 | ("a🌳".into(), regular()), | |
| 526 | ("e\u{301}שלום".into(), bold()), | |
| 527 | ("Z".into(), regular()), | |
| 528 | ]); | |
| 529 | let boundaries: Vec<_> = original | |
| 530 | .text() | |
| 531 | .char_indices() | |
| 532 | .map(|(n, _)| n) | |
| 533 | .chain([original.text().len()]) | |
| 534 | .collect(); | |
| 535 | for &start in &boundaries { | |
| 536 | for &end in boundaries.iter().filter(|&&end| end >= start) { | |
| 537 | let range = | |
| 538 | original.utf16_offset(start).unwrap()..original.utf16_offset(end).unwrap(); | |
| 539 | for replacement in ["", "👩👩👧👦", "xyz", "\u{301}"] { | |
| 540 | let mut edited = original.clone(); | |
| 541 | let undo = edited | |
| 542 | .apply(Edit { | |
| 543 | range: range.clone(), | |
| 544 | replacement: Paragraph::new(replacement.into(), bold()), | |
| 545 | }) | |
| 546 | .unwrap(); | |
| 547 | assert_eq!( | |
| 548 | edited.text(), | |
| 549 | format!( | |
| 550 | "{}{replacement}{}", | |
| 551 | &original.text()[..start], | |
| 552 | &original.text()[end..] | |
| 553 | ) | |
| 554 | ); | |
| 555 | let after = edited.clone(); | |
| 556 | let redo = edited.apply(undo).unwrap(); | |
| 557 | assert_eq!(edited, original); | |
| 558 | edited.apply(redo).unwrap(); | |
| 559 | assert_eq!(edited, after); | |
| 560 | } | |
| 561 | } | |
| 562 | let at = original.utf16_offset(start).unwrap(); | |
| 563 | let mut left = original.slice(0..at).unwrap(); | |
| 564 | let right = original | |
| 565 | .slice(at..original.utf16_offset(original.text.len()).unwrap()) | |
| 566 | .unwrap(); | |
| 567 | left.append(right).unwrap(); | |
| 568 | assert_eq!(left, original); | |
| 569 | } | |
| 570 | } | |
| 571 | } |
crates/onestore/src/pages.rs created+861| ... | ... | @@ -0,0 +1,861 @@ |
| 1 | use crate::{ | |
| 2 | Error, ExGuid, | |
| 3 | create::{current_timestamps, properties, string}, | |
| 4 | document::{Document, FieldValue, Kind}, | |
| 5 | write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions}, | |
| 6 | }; | |
| 7 | use serde::{Deserialize, Serialize}; | |
| 8 | use std::{ | |
| 9 | collections::{BTreeMap, BTreeSet}, | |
| 10 | sync::Arc, | |
| 11 | }; | |
| 12 | ||
| 13 | fn invalid(message: &'static str) -> Error { | |
| 14 | Error { offset: 0, message } | |
| 15 | } | |
| 16 | ||
| 17 | fn metadata_id(space: ExGuid) -> ExGuid { | |
| 18 | let mut id = ExGuid { | |
| 19 | guid: space.guid, | |
| 20 | n: 1, | |
| 21 | }; | |
| 22 | for (byte, salt) in id.guid.iter_mut().zip([ | |
| 23 | 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24, 0x35, 0xe8, | |
| 24 | ]) { | |
| 25 | *byte ^= salt; | |
| 26 | } | |
| 27 | id | |
| 28 | } | |
| 29 | ||
| 30 | fn metadata_guid(metadata: &crate::document::Element<'_>) -> Result<[u8; 16], Error> { | |
| 31 | metadata.extra[0] | |
| 32 | .iter() | |
| 33 | .find_map(|field| match field.value { | |
| 34 | FieldValue::Bytes(bytes) if field.id == 0x1c001c30 => bytes.try_into().ok(), | |
| 35 | _ => None, | |
| 36 | }) | |
| 37 | .ok_or_else(|| invalid("Page metadata needs its page identifier")) | |
| 38 | } | |
| 39 | ||
| 40 | fn set_references(object: &mut PropertyObject, property: u32, ids: &[ExGuid]) -> Result<(), Error> { | |
| 41 | let mut references = Vec::new(); | |
| 42 | for id in ids { | |
| 43 | references.extend_from_slice(&object.reference(*id)?); | |
| 44 | } | |
| 45 | let source = PropertyObject { | |
| 46 | jcid: object.jcid, | |
| 47 | bytes: properties(&[(property, references)])?, | |
| 48 | global_ids: Arc::clone(&object.global_ids), | |
| 49 | }; | |
| 50 | object.copy_property(&source, property) | |
| 51 | } | |
| 52 | ||
| 53 | /// Whether an edit preserves position or moves before a page (None appends). | |
| 54 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 55 | #[serde(deny_unknown_fields)] | |
| 56 | pub enum PagePosition { | |
| 57 | Keep, | |
| 58 | Before(Option<ExGuid>), | |
| 59 | } | |
| 60 | ||
| 61 | /// A page's explicit position and indentation, with a stable identity for a new series. | |
| 62 | /// Moving a page does not implicitly select its following subpages. | |
| 63 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 64 | #[serde(deny_unknown_fields)] | |
| 65 | pub struct PageEdit { | |
| 66 | guid: [u8; 16], | |
| 67 | space: ExGuid, | |
| 68 | level: u32, | |
| 69 | position: PagePosition, | |
| 70 | } | |
| 71 | ||
| 72 | impl PageEdit { | |
| 73 | /// Changes one page's indentation without changing its position. | |
| 74 | pub fn set_level(space: ExGuid, level: u32) -> Result<Self, Error> { | |
| 75 | Self::new(space, level, PagePosition::Keep) | |
| 76 | } | |
| 77 | ||
| 78 | /// Moves before an existing page space, or appends when `before` is None. | |
| 79 | pub fn move_to(space: ExGuid, before: Option<ExGuid>, level: u32) -> Result<Self, Error> { | |
| 80 | Self::new(space, level, PagePosition::Before(before)) | |
| 81 | } | |
| 82 | ||
| 83 | pub fn space(&self) -> ExGuid { | |
| 84 | self.space | |
| 85 | } | |
| 86 | ||
| 87 | pub fn level(&self) -> u32 { | |
| 88 | self.level | |
| 89 | } | |
| 90 | ||
| 91 | pub fn position(&self) -> PagePosition { | |
| 92 | self.position | |
| 93 | } | |
| 94 | ||
| 95 | /// Revises placement while retaining the selected page and allocated series identity. | |
| 96 | pub fn reposition(&self, position: PagePosition, level: u32) -> Result<Self, Error> { | |
| 97 | let edit = Self { | |
| 98 | position, | |
| 99 | level, | |
| 100 | ..self.clone() | |
| 101 | }; | |
| 102 | edit.validate()?; | |
| 103 | Ok(edit) | |
| 104 | } | |
| 105 | ||
| 106 | fn new(space: ExGuid, level: u32, position: PagePosition) -> Result<Self, Error> { | |
| 107 | let edit = Self { | |
| 108 | guid: fresh_guid()?, | |
| 109 | space, | |
| 110 | level, | |
| 111 | position, | |
| 112 | }; | |
| 113 | edit.validate()?; | |
| 114 | Ok(edit) | |
| 115 | } | |
| 116 | ||
| 117 | fn validate(&self) -> Result<(), Error> { | |
| 118 | if self.guid == [0; 16] || self.space.guid == [0; 16] || !(1..=3).contains(&self.level) { | |
| 119 | return Err(invalid( | |
| 120 | "Choose an existing page space and indentation level 1 through 3", | |
| 121 | )); | |
| 122 | } | |
| 123 | if let PagePosition::Before(Some(before)) = self.position | |
| 124 | && (before.guid == [0; 16] || before == self.space) | |
| 125 | { | |
| 126 | return Err(invalid("Choose a different page as the movement anchor")); | |
| 127 | } | |
| 128 | Ok(()) | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | pub(crate) fn edit_pages( | |
| 133 | source: &[u8], | |
| 134 | edits: &[PageEdit], | |
| 135 | removals: &[ExGuid], | |
| 136 | ) -> Result<Vec<u8>, Error> { | |
| 137 | if !edits.is_empty() && !removals.is_empty() { | |
| 138 | return Err(invalid( | |
| 139 | "Choose page placement or page removal for one operation", | |
| 140 | )); | |
| 141 | } | |
| 142 | let removed: BTreeSet<_> = removals.iter().copied().collect(); | |
| 143 | if removed.len() != removals.len() || removed.iter().any(|sid| sid.guid == [0; 16]) { | |
| 144 | return Err(invalid("Choose distinct existing page spaces for removal")); | |
| 145 | } | |
| 146 | let mut selected = BTreeMap::new(); | |
| 147 | let mut guids = BTreeSet::new(); | |
| 148 | for edit in edits { | |
| 149 | edit.validate()?; | |
| 150 | if selected.insert(edit.space, edit).is_some() || !guids.insert(edit.guid) { | |
| 151 | return Err(invalid( | |
| 152 | "Use distinct page and edit identities within a batch", | |
| 153 | )); | |
| 154 | } | |
| 155 | } | |
| 156 | write_revisions(source, |index| { | |
| 157 | let document = Document::parse(index)?; | |
| 158 | let pages = document.pages()?; | |
| 159 | let view = document.active(document.root)?; | |
| 160 | let section_id = view.roots[&1]; | |
| 161 | let section_node = &view.nodes[&section_id]; | |
| 162 | if !matches!(section_node.kind, Kind::Section { .. }) | |
| 163 | || section_node.extra[0] | |
| 164 | .iter() | |
| 165 | .any(|field| field.id == 0x88001cde) | |
| 166 | { | |
| 167 | return Err(invalid("Choose an editable section for page editing")); | |
| 168 | } | |
| 169 | let mut order = Vec::new(); | |
| 170 | let mut levels = BTreeMap::new(); | |
| 171 | let mut page_guids = BTreeMap::new(); | |
| 172 | for (sid, _) in &pages { | |
| 173 | let page = document.active(*sid)?; | |
| 174 | let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { | |
| 175 | return Err(invalid("Page metadata is unavailable")); | |
| 176 | }; | |
| 177 | let Kind::Metadata { level, .. } = metadata.kind else { | |
| 178 | return Err(invalid("Choose ordinary pages with page metadata")); | |
| 179 | }; | |
| 180 | let level = level.unwrap_or(1); | |
| 181 | if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { | |
| 182 | return Err(invalid( | |
| 183 | "Each page space must contain one page with a valid level", | |
| 184 | )); | |
| 185 | } | |
| 186 | if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() { | |
| 187 | return Err(invalid("Pages must have distinct metadata identifiers")); | |
| 188 | } | |
| 189 | order.push(*sid); | |
| 190 | } | |
| 191 | let original_order = order.clone(); | |
| 192 | let original_levels = levels.clone(); | |
| 193 | let mut series_by_head = BTreeMap::new(); | |
| 194 | let mut copies = BTreeMap::new(); | |
| 195 | let mut copy_ids = BTreeSet::new(); | |
| 196 | for oid in &section_node.children { | |
| 197 | let series = &view.nodes[oid]; | |
| 198 | let Some(head) = series.spaces.first() else { | |
| 199 | return Err(invalid("A page series must contain at least one page")); | |
| 200 | }; | |
| 201 | if levels.get(head) != Some(&1) | |
| 202 | || series.spaces[1..] | |
| 203 | .iter() | |
| 204 | .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) | |
| 205 | { | |
| 206 | return Err(invalid( | |
| 207 | "A page series must start with its only top-level page", | |
| 208 | )); | |
| 209 | } | |
| 210 | if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { | |
| 211 | series_by_head.insert(*retained, *oid); | |
| 212 | } | |
| 213 | if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { | |
| 214 | let FieldValue::Objects(ids) = &field.value else { | |
| 215 | return Err(invalid("Page metadata copies must be object references")); | |
| 216 | }; | |
| 217 | if ids.len() != series.spaces.len() { | |
| 218 | return Err(invalid("Page metadata copies must match their series")); | |
| 219 | } | |
| 220 | for id in ids { | |
| 221 | if !copy_ids.insert(*id) | |
| 222 | || !matches!( | |
| 223 | view.nodes.get(id).map(|n| &n.kind), | |
| 224 | Some(Kind::Metadata { .. }) | |
| 225 | ) | |
| 226 | { | |
| 227 | return Err(invalid("Each page needs its own ordinary metadata copy")); | |
| 228 | } | |
| 229 | let sid = page_guids | |
| 230 | .get(&metadata_guid(&view.nodes[id])?) | |
| 231 | .filter(|sid| series.spaces.contains(sid)) | |
| 232 | .ok_or_else(|| { | |
| 233 | invalid("Metadata copy must identify a page in its series") | |
| 234 | })?; | |
| 235 | if copies.insert(*sid, *id).is_some() { | |
| 236 | return Err(invalid("Each page needs its own ordinary metadata copy")); | |
| 237 | } | |
| 238 | } | |
| 239 | } | |
| 240 | } | |
| 241 | for sid in &removed { | |
| 242 | if !levels.contains_key(sid) { | |
| 243 | return Err(invalid("The selected page is no longer in the section")); | |
| 244 | } | |
| 245 | } | |
| 246 | order.retain(|sid| !removed.contains(sid)); | |
| 247 | if !removed.is_empty() | |
| 248 | && let Some(first) = order.first() | |
| 249 | { | |
| 250 | levels.insert(*first, 1); | |
| 251 | } | |
| 252 | for edit in edits { | |
| 253 | let Some(at) = order.iter().position(|sid| *sid == edit.space) else { | |
| 254 | return Err(invalid("The selected page is no longer in the section")); | |
| 255 | }; | |
| 256 | levels.insert(edit.space, edit.level); | |
| 257 | if let PagePosition::Before(before) = edit.position { | |
| 258 | order.remove(at); | |
| 259 | let position = match before { | |
| 260 | None => order.len(), | |
| 261 | Some(before) => { | |
| 262 | order.iter().position(|sid| *sid == before).ok_or_else(|| { | |
| 263 | invalid("The movement anchor is no longer in the section") | |
| 264 | })? | |
| 265 | } | |
| 266 | }; | |
| 267 | order.insert(position, edit.space); | |
| 268 | } | |
| 269 | } | |
| 270 | if order.first().is_some_and(|sid| levels[sid] != 1) { | |
| 271 | return Err(invalid( | |
| 272 | "The section's first page must have indentation level 1", | |
| 273 | )); | |
| 274 | } | |
| 275 | if order == original_order && levels == original_levels { | |
| 276 | return Ok(BTreeMap::new()); | |
| 277 | } | |
| 278 | let raw = index.resolve_active(document.root)?; | |
| 279 | let mut groups: Vec<Vec<ExGuid>> = Vec::new(); | |
| 280 | for sid in order { | |
| 281 | if levels[&sid] == 1 { | |
| 282 | groups.push(Vec::new()); | |
| 283 | } | |
| 284 | groups.last_mut().unwrap().push(sid); | |
| 285 | } | |
| 286 | let mut changes = BTreeMap::new(); | |
| 287 | for sid in &removed { | |
| 288 | let page = index.resolve_active(*sid)?; | |
| 289 | let manifest_id = page.roots[&1]; | |
| 290 | let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?; | |
| 291 | if manifest.jcid != 0x60037 { | |
| 292 | return Err(invalid("Choose ordinary pages for removal")); | |
| 293 | } | |
| 294 | manifest.bytes = properties(&[])?; | |
| 295 | let metadata_id = page.roots[&2]; | |
| 296 | let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?; | |
| 297 | metadata.set(&[(0x88001de9, &[])])?; | |
| 298 | changes.insert( | |
| 299 | *sid, | |
| 300 | RevisionEdit::Update(BTreeMap::from([ | |
| 301 | (manifest_id, manifest), | |
| 302 | (metadata_id, metadata), | |
| 303 | ])), | |
| 304 | ); | |
| 305 | } | |
| 306 | let mut replacements = BTreeMap::new(); | |
| 307 | let mut children = Vec::new(); | |
| 308 | for spaces in groups { | |
| 309 | let head = spaces[0]; | |
| 310 | let old_id = series_by_head.get(&head).copied(); | |
| 311 | let (id, mut series) = if let Some(id) = old_id { | |
| 312 | (id, PropertyObject::from_object(&raw.objects[&id])?) | |
| 313 | } else { | |
| 314 | let edit = selected | |
| 315 | .get(&head) | |
| 316 | .ok_or_else(|| invalid("A new series must start at an edited page"))?; | |
| 317 | let id = ExGuid { | |
| 318 | guid: edit.guid, | |
| 319 | n: 1, | |
| 320 | }; | |
| 321 | if raw.objects.contains_key(&id) { | |
| 322 | return Err(invalid("The new page-series identity already exists")); | |
| 323 | } | |
| 324 | ( | |
| 325 | id, | |
| 326 | PropertyObject { | |
| 327 | jcid: 0x60008, | |
| 328 | bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, | |
| 329 | global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), | |
| 330 | }, | |
| 331 | ) | |
| 332 | }; | |
| 333 | children.push(id); | |
| 334 | let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); | |
| 335 | if !membership_changed | |
| 336 | && !spaces.iter().any(|sid| { | |
| 337 | levels[sid] != original_levels[sid] | |
| 338 | || copies.get(sid).is_some_and(|id| { | |
| 339 | matches!(view.nodes[id].kind, Kind::Metadata { level, .. } | |
| 340 | if level.unwrap_or(1) != levels[sid]) | |
| 341 | }) | |
| 342 | }) | |
| 343 | { | |
| 344 | continue; | |
| 345 | } | |
| 346 | let mut metadata_ids = Vec::new(); | |
| 347 | for sid in &spaces { | |
| 348 | let page = index.resolve_active(*sid)?; | |
| 349 | let page_id = page.roots[&2]; | |
| 350 | let original = PropertyObject::from_object(&page.objects[&page_id])?; | |
| 351 | if old_id.is_none() && *sid == head { | |
| 352 | series.copy_property(&original, 0x18001c65)?; | |
| 353 | } | |
| 354 | if levels[sid] != original_levels[sid] { | |
| 355 | let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; | |
| 356 | metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; | |
| 357 | changes.insert( | |
| 358 | *sid, | |
| 359 | RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), | |
| 360 | ); | |
| 361 | } | |
| 362 | let copy_id = copies | |
| 363 | .get(sid) | |
| 364 | .copied() | |
| 365 | .unwrap_or_else(|| metadata_id(*sid)); | |
| 366 | let mut copy = match raw.objects.get(&copy_id) { | |
| 367 | Some(object) => { | |
| 368 | if object.jcid != 0x20030 | |
| 369 | || (!copies.contains_key(sid) && copy_ids.contains(&copy_id)) | |
| 370 | { | |
| 371 | return Err(invalid("Page metadata identities overlap")); | |
| 372 | } | |
| 373 | PropertyObject::from_object(object)? | |
| 374 | } | |
| 375 | None => original, | |
| 376 | }; | |
| 377 | copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; | |
| 378 | copy.reference(copy_id)?; | |
| 379 | if replacements.insert(copy_id, copy).is_some() { | |
| 380 | return Err(invalid("Page metadata identities overlap")); | |
| 381 | } | |
| 382 | metadata_ids.push(copy_id); | |
| 383 | } | |
| 384 | if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { | |
| 385 | set_references(&mut series, 0x2c001d63, &spaces)?; | |
| 386 | set_references(&mut series, 0x24003442, &metadata_ids)?; | |
| 387 | if replacements.insert(id, series).is_some() { | |
| 388 | return Err(invalid("Page-series and metadata identities overlap")); | |
| 389 | } | |
| 390 | } | |
| 391 | } | |
| 392 | if children != section_node.children { | |
| 393 | let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?; | |
| 394 | set_references(&mut parent, 0x24001c20, &children)?; | |
| 395 | replacements.insert(section_id, parent); | |
| 396 | } | |
| 397 | changes.insert(document.root, RevisionEdit::Update(replacements)); | |
| 398 | Ok(changes) | |
| 399 | }) | |
| 400 | } | |
| 401 | ||
| 402 | /// An empty top-level page with stable identities and creation time. | |
| 403 | /// Retain the intent across retries; an existing page identity rejects duplicate creation. | |
| 404 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 405 | #[serde(deny_unknown_fields)] | |
| 406 | pub struct PageCreation { | |
| 407 | guid: [u8; 16], | |
| 408 | series_guid: [u8; 16], | |
| 409 | before: Option<ExGuid>, | |
| 410 | title: Option<String>, | |
| 411 | author: String, | |
| 412 | created: u32, | |
| 413 | } | |
| 414 | ||
| 415 | impl PageCreation { | |
| 416 | /// Appends a page, or inserts before the first page space of an existing series. | |
| 417 | /// `Some("")` creates an empty title field; `None` creates a page without a title node. | |
| 418 | /// The page has no body outlines or generated date/time text and does not apply a template. | |
| 419 | pub fn new(before: Option<ExGuid>, title: Option<&str>, author: &str) -> Result<Self, Error> { | |
| 420 | let page = Self { | |
| 421 | guid: fresh_guid()?, | |
| 422 | series_guid: fresh_guid()?, | |
| 423 | before, | |
| 424 | title: title.map(str::to_owned), | |
| 425 | author: author.to_owned(), | |
| 426 | created: current_timestamps()?.0, | |
| 427 | }; | |
| 428 | page.validate()?; | |
| 429 | Ok(page) | |
| 430 | } | |
| 431 | ||
| 432 | pub fn space(&self) -> ExGuid { | |
| 433 | ExGuid { | |
| 434 | guid: self.guid, | |
| 435 | n: 1, | |
| 436 | } | |
| 437 | } | |
| 438 | ||
| 439 | pub fn object(&self) -> ExGuid { | |
| 440 | ExGuid { | |
| 441 | guid: self.guid, | |
| 442 | n: 12, | |
| 443 | } | |
| 444 | } | |
| 445 | ||
| 446 | pub fn title_object(&self) -> Option<ExGuid> { | |
| 447 | self.title.as_ref().map(|_| ExGuid { | |
| 448 | guid: self.guid, | |
| 449 | n: 16, | |
| 450 | }) | |
| 451 | } | |
| 452 | ||
| 453 | /// Changes the insertion anchor while retaining all identities and creation metadata. | |
| 454 | pub fn reposition(&self, before: Option<ExGuid>) -> Result<Self, Error> { | |
| 455 | let mut page = self.clone(); | |
| 456 | page.before = before; | |
| 457 | page.validate()?; | |
| 458 | Ok(page) | |
| 459 | } | |
| 460 | ||
| 461 | fn validate(&self) -> Result<(), Error> { | |
| 462 | if self.guid == [0; 16] | |
| 463 | || self.series_guid == [0; 16] | |
| 464 | || self.guid == self.series_guid | |
| 465 | || self.author.contains('\0') | |
| 466 | || self.before.is_some_and(|id| id.guid == [0; 16]) | |
| 467 | || self | |
| 468 | .title | |
| 469 | .as_ref() | |
| 470 | .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) | |
| 471 | { | |
| 472 | return Err(invalid( | |
| 473 | "Use a new page identity, an existing page anchor, and ordinary single-line title text", | |
| 474 | )); | |
| 475 | } | |
| 476 | Ok(()) | |
| 477 | } | |
| 478 | ||
| 479 | pub(crate) fn apply(&self, source: &[u8]) -> Result<Vec<u8>, Error> { | |
| 480 | self.validate()?; | |
| 481 | write_revisions(source, |index| { | |
| 482 | if index.spaces.contains_key(&self.space()) { | |
| 483 | return Err(invalid( | |
| 484 | "This page identity already exists; reconcile the original creation", | |
| 485 | )); | |
| 486 | } | |
| 487 | let document = Document::parse(index)?; | |
| 488 | document.pages()?; | |
| 489 | let view = document.active(document.root)?; | |
| 490 | let section_id = view.roots[&1]; | |
| 491 | let section_node = &view.nodes[&section_id]; | |
| 492 | if !matches!(section_node.kind, Kind::Section { .. }) | |
| 493 | || section_node.extra[0] | |
| 494 | .iter() | |
| 495 | .any(|field| field.id == 0x88001cde) | |
| 496 | { | |
| 497 | return Err(invalid("Choose an editable section for the new page")); | |
| 498 | } | |
| 499 | let position = match self.before { | |
| 500 | None => section_node.children.len(), | |
| 501 | Some(before) => section_node | |
| 502 | .children | |
| 503 | .iter() | |
| 504 | .position(|id| view.nodes[id].spaces.first() == Some(&before)) | |
| 505 | .ok_or_else(|| { | |
| 506 | invalid("Insert before the first page of an existing series, or append") | |
| 507 | })?, | |
| 508 | }; | |
| 509 | let raw = index.resolve_active(document.root)?; | |
| 510 | let id = |n| ExGuid { guid: self.guid, n }; | |
| 511 | let reference = |n: u32| n.to_le_bytes().to_vec(); | |
| 512 | let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) | |
| 513 | .to_le_bytes() | |
| 514 | .to_vec(); | |
| 515 | let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec()); | |
| 516 | let mut table = BTreeMap::from([(0, self.guid)]); | |
| 517 | let metadata_id = metadata_id(self.space()); | |
| 518 | table.insert(1, metadata_id.guid); | |
| 519 | let table = Arc::new(table); | |
| 520 | let object = |jcid, values: Vec<_>| -> Result<_, Error> { | |
| 521 | Ok(PropertyObject { | |
| 522 | jcid, | |
| 523 | bytes: properties(&values)?, | |
| 524 | global_ids: Arc::clone(&table), | |
| 525 | }) | |
| 526 | }; | |
| 527 | let title = self.title.as_deref().unwrap_or_default().trim_start(); | |
| 528 | let metadata = vec![ | |
| 529 | (0x1c001c30, self.guid.to_vec()), | |
| 530 | (0x1c001cf3, string(title)), | |
| 531 | (0x14001d82, reference(40)), | |
| 532 | (0x1400348b, reference(40)), | |
| 533 | (0x14001dff, reference(1)), | |
| 534 | (0x18001c65, timestamp.clone()), | |
| 535 | ]; | |
| 536 | let mut page = vec![ | |
| 537 | modified(), | |
| 538 | (0x1c001d75, string(&self.author)), | |
| 539 | (0x1c001d3c, string("")), | |
| 540 | ]; | |
| 541 | if self.title.is_some() { | |
| 542 | page.push((0x24001d5f, reference(13))); | |
| 543 | } | |
| 544 | let mut objects = BTreeMap::from([ | |
| 545 | (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?), | |
| 546 | (id(11), object(0x20030, metadata.clone())?), | |
| 547 | (id(12), object(0x6000b, page)?), | |
| 548 | ]); | |
| 549 | if let Some(title) = &self.title { | |
| 550 | for (n, jcid, values) in [ | |
| 551 | ( | |
| 552 | 13, | |
| 553 | 0x6002c, | |
| 554 | vec![ | |
| 555 | modified(), | |
| 556 | (0x24001c20, reference(14)), | |
| 557 | (0x14001c14, 0_f32.to_le_bytes().to_vec()), | |
| 558 | (0x14001c15, 0_f32.to_le_bytes().to_vec()), | |
| 559 | ], | |
| 560 | ), | |
| 561 | ( | |
| 562 | 14, | |
| 563 | 0x6000c, | |
| 564 | vec![ | |
| 565 | modified(), | |
| 566 | (0x24001c20, reference(15)), | |
| 567 | (0x0c001c03, vec![1]), | |
| 568 | ], | |
| 569 | ), | |
| 570 | ( | |
| 571 | 15, | |
| 572 | 0x6000d, | |
| 573 | vec![ | |
| 574 | modified(), | |
| 575 | (0x24001c1f, reference(16)), | |
| 576 | (0x0c001c03, vec![1]), | |
| 577 | (0x20001d78, reference(17)), | |
| 578 | (0x20001d79, reference(17)), | |
| 579 | (0x14001d09, self.created.to_le_bytes().to_vec()), | |
| 580 | (0x88001cb4, vec![]), | |
| 581 | ], | |
| 582 | ), | |
| 583 | ( | |
| 584 | 16, | |
| 585 | 0x6000e, | |
| 586 | vec![ | |
| 587 | modified(), | |
| 588 | (0x1c001c22, string(title)), | |
| 589 | (0x24001e13, reference(19)), | |
| 590 | (0x2000342c, reference(18)), | |
| 591 | (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), | |
| 592 | (0x88001cb4, vec![]), | |
| 593 | ], | |
| 594 | ), | |
| 595 | (17, 0x120001, vec![(0x1c001d75, string(&self.author))]), | |
| 596 | ( | |
| 597 | 18, | |
| 598 | 0x12004d, | |
| 599 | vec![ | |
| 600 | (0x1c00345a, string("PageTitle")), | |
| 601 | (0x1c001c0a, string("Calibri")), | |
| 602 | (0x10001c0b, 34_u16.to_le_bytes().to_vec()), | |
| 603 | ], | |
| 604 | ), | |
| 605 | (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), | |
| 606 | ] { | |
| 607 | objects.insert(id(n), object(jcid, values)?); | |
| 608 | } | |
| 609 | } | |
| 610 | let series = object( | |
| 611 | 0x60008, | |
| 612 | vec![ | |
| 613 | (0x1c001c30, self.series_guid.to_vec()), | |
| 614 | (0x18001c65, timestamp), | |
| 615 | (0x2c001d63, reference(1)), | |
| 616 | (0x24003442, reference(257)), | |
| 617 | ], | |
| 618 | )?; | |
| 619 | if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) { | |
| 620 | return Err(invalid("The new page's section identities already exist")); | |
| 621 | } | |
| 622 | let mut parent = PropertyObject::from_object(&raw.objects[&section_id])?; | |
| 623 | let mut children = section_node.children.clone(); | |
| 624 | children.insert(position, id(2)); | |
| 625 | let mut references = Vec::new(); | |
| 626 | for child in children { | |
| 627 | references.extend_from_slice(&parent.reference(child)?); | |
| 628 | } | |
| 629 | parent.set(&[(0x24001c20, &references)])?; | |
| 630 | Ok(BTreeMap::from([ | |
| 631 | ( | |
| 632 | document.root, | |
| 633 | RevisionEdit::Update(BTreeMap::from([ | |
| 634 | (section_id, parent), | |
| 635 | (id(2), series), | |
| 636 | (metadata_id, object(0x20030, metadata)?), | |
| 637 | ])), | |
| 638 | ), | |
| 639 | ( | |
| 640 | self.space(), | |
| 641 | RevisionEdit::Create { | |
| 642 | roots: BTreeMap::from([(1, id(10)), (2, id(11))]), | |
| 643 | objects, | |
| 644 | }, | |
| 645 | ), | |
| 646 | ])) | |
| 647 | }) | |
| 648 | } | |
| 649 | } | |
| 650 | ||
| 651 | #[cfg(test)] | |
| 652 | mod tests { | |
| 653 | use super::*; | |
| 654 | use crate::{RevisionIndex, Store}; | |
| 655 | ||
| 656 | #[test] | |
| 657 | fn page_movement_restores_an_optional_metadata_cache_from_history() { | |
| 658 | let source = | |
| 659 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | |
| 660 | let store = Store::parse(source).unwrap(); | |
| 661 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 662 | let document = Document::parse(&index).unwrap(); | |
| 663 | let sid = document.pages().unwrap()[3].0; | |
| 664 | let view = document.active(document.root).unwrap(); | |
| 665 | let series_id = view.nodes[&view.roots[&1]].children[3]; | |
| 666 | let copy_id = metadata_id(sid); | |
| 667 | for unknown in [false, true] { | |
| 668 | let source = if unknown { | |
| 669 | write_revisions(source, |index| { | |
| 670 | let raw = index.resolve_active(index.root)?; | |
| 671 | let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?; | |
| 672 | copy.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; | |
| 673 | Ok(BTreeMap::from([( | |
| 674 | index.root, | |
| 675 | RevisionEdit::Update(BTreeMap::from([(copy_id, copy)])), | |
| 676 | )])) | |
| 677 | }) | |
| 678 | .unwrap() | |
| 679 | } else { | |
| 680 | source.to_vec() | |
| 681 | }; | |
| 682 | let source = write_revisions(&source, |index| { | |
| 683 | let raw = index.resolve_active(index.root)?; | |
| 684 | let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; | |
| 685 | series.remove(&[0x24003442])?; | |
| 686 | Ok(BTreeMap::from([( | |
| 687 | index.root, | |
| 688 | RevisionEdit::Update(BTreeMap::from([(series_id, series)])), | |
| 689 | )])) | |
| 690 | }) | |
| 691 | .unwrap(); | |
| 692 | for changed in [false, true] { | |
| 693 | for unrelated_edit in [false, true] { | |
| 694 | let result = write_revisions(&source, |index| { | |
| 695 | let raw = index.resolve_active(index.root)?; | |
| 696 | assert!(!raw.reachable()?.contains(&copy_id)); | |
| 697 | let mut copy = PropertyObject::from_object(&raw.objects[&copy_id])?; | |
| 698 | if changed { | |
| 699 | copy.set(&[(0x14001dff, &2_u32.to_le_bytes())])?; | |
| 700 | } | |
| 701 | let mut objects = BTreeMap::from([(copy_id, copy)]); | |
| 702 | if unrelated_edit { | |
| 703 | let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; | |
| 704 | series.set(&[(0x1400abcd, &88_u32.to_le_bytes())])?; | |
| 705 | objects.insert(series_id, series); | |
| 706 | } | |
| 707 | Ok(BTreeMap::from([( | |
| 708 | index.root, | |
| 709 | RevisionEdit::Update(objects), | |
| 710 | )])) | |
| 711 | }); | |
| 712 | assert_eq!( | |
| 713 | result.unwrap_err().message, | |
| 714 | "Edited object is not reachable in the resulting revision" | |
| 715 | ); | |
| 716 | } | |
| 717 | } | |
| 718 | let written = | |
| 719 | edit_pages(&source, &[PageEdit::set_level(sid, 2).unwrap()], &[]).unwrap(); | |
| 720 | let store = Store::parse(&written).unwrap(); | |
| 721 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 722 | index.validate_current().unwrap(); | |
| 723 | assert_eq!(Document::parse(&index).unwrap().pages().unwrap().len(), 9); | |
| 724 | let current = index | |
| 725 | .resolve( | |
| 726 | index.root, | |
| 727 | index.spaces[&index.root].labels[&(ExGuid::default(), 1)], | |
| 728 | ) | |
| 729 | .unwrap(); | |
| 730 | assert!(current.reachable().unwrap().contains(&copy_id)); | |
| 731 | let copy = PropertyObject::from_object(&current.objects[&copy_id]).unwrap(); | |
| 732 | let properties = crate::PropertySets::parse(&copy.bytes).unwrap(); | |
| 733 | assert_eq!(unknown, properties.sets[0].iter().any(|field| field.id == 0x1400abcd | |
| 734 | && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); | |
| 735 | let old_store = Store::parse(&source).unwrap(); | |
| 736 | let old_index = RevisionIndex::parse(&old_store).unwrap(); | |
| 737 | for (space, revisions) in &old_index.spaces { | |
| 738 | for revision in revisions.revisions.keys() { | |
| 739 | assert_eq!( | |
| 740 | format!("{:?}", old_index.resolve(*space, *revision).unwrap()), | |
| 741 | format!("{:?}", index.resolve(*space, *revision).unwrap()) | |
| 742 | ); | |
| 743 | } | |
| 744 | } | |
| 745 | if !unknown && let Some(output) = std::env::var_os("ONESTORE_PAGE_MOVEMENT_OUTPUT") { | |
| 746 | let output = std::path::Path::new(&output); | |
| 747 | assert!(output.is_absolute()); | |
| 748 | for (name, bytes) in [ | |
| 749 | ("optional-cache-source", source), | |
| 750 | ("optional-cache", written), | |
| 751 | ] { | |
| 752 | let path = output.join(name); | |
| 753 | std::fs::create_dir_all(&path).unwrap(); | |
| 754 | std::fs::write(path.join("Lifecycle.one"), bytes).unwrap(); | |
| 755 | } | |
| 756 | } | |
| 757 | } | |
| 758 | } | |
| 759 | ||
| 760 | #[test] | |
| 761 | fn page_movement_rejects_empty_manifests_and_series() { | |
| 762 | let source = | |
| 763 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | |
| 764 | let store = Store::parse(source).unwrap(); | |
| 765 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 766 | let document = Document::parse(&index).unwrap(); | |
| 767 | let sid = document.pages().unwrap()[3].0; | |
| 768 | for empty_series in [false, true] { | |
| 769 | let invalid_source = write_revisions(source, |index| { | |
| 770 | let target = if empty_series { index.root } else { sid }; | |
| 771 | let raw = index.resolve_active(target)?; | |
| 772 | let id = if empty_series { | |
| 773 | let view = document.active(document.root).unwrap(); | |
| 774 | view.nodes[&view.roots[&1]].children[3] | |
| 775 | } else { | |
| 776 | raw.roots[&1] | |
| 777 | }; | |
| 778 | let mut object = PropertyObject::from_object(&raw.objects[&id])?; | |
| 779 | set_references( | |
| 780 | &mut object, | |
| 781 | if empty_series { 0x2c001d63 } else { 0x24001c1f }, | |
| 782 | &[], | |
| 783 | )?; | |
| 784 | Ok(BTreeMap::from([( | |
| 785 | target, | |
| 786 | RevisionEdit::Update(BTreeMap::from([(id, object)])), | |
| 787 | )])) | |
| 788 | }) | |
| 789 | .unwrap(); | |
| 790 | assert!( | |
| 791 | edit_pages( | |
| 792 | &invalid_source, | |
| 793 | &[PageEdit::set_level(sid, 2).unwrap()], | |
| 794 | &[] | |
| 795 | ) | |
| 796 | .is_err() | |
| 797 | ); | |
| 798 | } | |
| 799 | } | |
| 800 | ||
| 801 | #[test] | |
| 802 | fn page_moves_retain_unknown_series_and_metadata_properties() { | |
| 803 | let source = | |
| 804 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | |
| 805 | let store = Store::parse(source).unwrap(); | |
| 806 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 807 | let document = Document::parse(&index).unwrap(); | |
| 808 | let pages = document.pages().unwrap(); | |
| 809 | let view = document.active(document.root).unwrap(); | |
| 810 | let series_id = view.nodes[&view.roots[&1]].children[3]; | |
| 811 | let field = view.nodes[&series_id].extra[0] | |
| 812 | .iter() | |
| 813 | .find(|f| f.id == 0x24003442) | |
| 814 | .unwrap(); | |
| 815 | let FieldValue::Objects(copies) = &field.value else { | |
| 816 | panic!() | |
| 817 | }; | |
| 818 | let copy_id = copies[0]; | |
| 819 | let source = write_revisions(source, |index| { | |
| 820 | let raw = index.resolve_active(index.root)?; | |
| 821 | let mut objects = BTreeMap::new(); | |
| 822 | for id in [series_id, copy_id] { | |
| 823 | let mut object = PropertyObject::from_object(&raw.objects[&id])?; | |
| 824 | object.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; | |
| 825 | objects.insert(id, object); | |
| 826 | } | |
| 827 | Ok(BTreeMap::from([( | |
| 828 | index.root, | |
| 829 | RevisionEdit::Update(objects), | |
| 830 | )])) | |
| 831 | }) | |
| 832 | .unwrap(); | |
| 833 | for edits in [ | |
| 834 | vec![PageEdit::set_level(pages[3].0, 2).unwrap()], | |
| 835 | pages[3..6] | |
| 836 | .iter() | |
| 837 | .enumerate() | |
| 838 | .map(|(i, (sid, _))| PageEdit::move_to(*sid, None, i as u32 + 1).unwrap()) | |
| 839 | .collect(), | |
| 840 | ] { | |
| 841 | let written = edit_pages(&source, &edits, &[]).unwrap(); | |
| 842 | let old_store = Store::parse(&source).unwrap(); | |
| 843 | let new_store = Store::parse(&written).unwrap(); | |
| 844 | let old = RevisionIndex::parse(&old_store).unwrap(); | |
| 845 | let new = RevisionIndex::parse(&new_store).unwrap(); | |
| 846 | let old_id = old.spaces[&old.root].labels[&(ExGuid::default(), 1)]; | |
| 847 | let new_id = new.spaces[&new.root].labels[&(ExGuid::default(), 1)]; | |
| 848 | assert_eq!( | |
| 849 | format!("{:?}", old.resolve(old.root, old_id).unwrap()), | |
| 850 | format!("{:?}", new.resolve(new.root, old_id).unwrap()) | |
| 851 | ); | |
| 852 | let current = new.resolve(new.root, new_id).unwrap(); | |
| 853 | for id in [series_id, copy_id] { | |
| 854 | let object = PropertyObject::from_object(&current.objects[&id]).unwrap(); | |
| 855 | let properties = crate::PropertySets::parse(&object.bytes).unwrap(); | |
| 856 | assert!(properties.sets[0].iter().any(|field| field.id == 0x1400abcd | |
| 857 | && matches!(field.value, crate::Value::Bytes(bytes) if bytes == 77_u32.to_le_bytes()))); | |
| 858 | } | |
| 859 | } | |
| 860 | } | |
| 861 | } |
crates/onestore/src/store.rs+1-1| ... | ... | @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; |
| 3 | 3 | use std::fmt; |
| 4 | 4 | use std::ops::Range; |
| 5 | 5 | |
| 6 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] | |
| 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] | |
| 7 | 7 | pub struct Error { |
| 8 | 8 | /// Parser byte offset; zero also represents errors without a byte location. |
| 9 | 9 | pub offset: usize, |
crates/onestore/tests/page_model.rs created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | use onestore::{ | |
| 2 | RevisionIndex, Store, | |
| 3 | document::Document, | |
| 4 | page::{Page, PageObject, ParagraphContent}, | |
| 5 | }; | |
| 6 | use std::{collections::BTreeSet, fs, path::Path}; | |
| 7 | ||
| 8 | fn sections(directory: &Path, out: &mut Vec<std::path::PathBuf>) { | |
| 9 | for entry in fs::read_dir(directory).unwrap() { | |
| 10 | let path = entry.unwrap().path(); | |
| 11 | if path.is_dir() { | |
| 12 | if !matches!( | |
| 13 | path.file_name().and_then(|n| n.to_str()), | |
| 14 | Some("private" | "recovered") | |
| 15 | ) { | |
| 16 | sections(&path, out); | |
| 17 | } | |
| 18 | } else if path.extension().is_some_and(|e| e == "one") { | |
| 19 | out.push(path); | |
| 20 | } | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | fn identities(page: &Page) -> Vec<onestore::ExGuid> { | |
| 25 | fn paragraphs(list: &[onestore::page::PageParagraph], ids: &mut Vec<onestore::ExGuid>) { | |
| 26 | for paragraph in list { | |
| 27 | ids.push(paragraph.id); | |
| 28 | match &paragraph.content { | |
| 29 | ParagraphContent::Text(text) => ids.push(text.id), | |
| 30 | ParagraphContent::Table(table) => { | |
| 31 | ids.push(table.id); | |
| 32 | for row in &table.rows { | |
| 33 | ids.push(row.id); | |
| 34 | for cell in &row.cells { | |
| 35 | ids.push(cell.id); | |
| 36 | paragraphs(&cell.paragraphs, ids); | |
| 37 | ids.extend(cell.unsupported.iter().map(|u| u.id)); | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
| 41 | ParagraphContent::Unsupported(unsupported) => ids.push(unsupported.id), | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
| 45 | let mut ids = Vec::new(); | |
| 46 | for object in &page.objects { | |
| 47 | ids.push(object.id()); | |
| 48 | match object { | |
| 49 | PageObject::Outline(outline) => { | |
| 50 | paragraphs(&outline.paragraphs, &mut ids); | |
| 51 | ids.extend(outline.unsupported.iter().map(|u| u.id)); | |
| 52 | } | |
| 53 | PageObject::Title(title) => { | |
| 54 | for outline in &title.outlines { | |
| 55 | ids.push(outline.id); | |
| 56 | paragraphs(&outline.paragraphs, &mut ids); | |
| 57 | ids.extend(outline.unsupported.iter().map(|u| u.id)); | |
| 58 | } | |
| 59 | } | |
| 60 | PageObject::Image(_) | PageObject::Unsupported(_) => {} | |
| 61 | } | |
| 62 | } | |
| 63 | ids | |
| 64 | } | |
| 65 | ||
| 66 | #[test] | |
| 67 | fn every_corpus_page_builds_a_model_with_distinct_identities() { | |
| 68 | let mut files = Vec::new(); | |
| 69 | sections( | |
| 70 | Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")), | |
| 71 | &mut files, | |
| 72 | ); | |
| 73 | files.sort(); | |
| 74 | assert!(files.len() > 300, "{} sections", files.len()); | |
| 75 | let mut pages = 0; | |
| 76 | let mut failures = Vec::new(); | |
| 77 | for path in &files { | |
| 78 | let bytes = fs::read(path).unwrap(); | |
| 79 | let Ok(store) = Store::parse(&bytes) else { | |
| 80 | continue; | |
| 81 | }; | |
| 82 | let Ok(index) = RevisionIndex::parse(&store) else { | |
| 83 | continue; | |
| 84 | }; | |
| 85 | let Ok(document) = Document::parse(&index) else { | |
| 86 | continue; | |
| 87 | }; | |
| 88 | let Ok(listed) = document.pages() else { | |
| 89 | continue; | |
| 90 | }; | |
| 91 | for (space, _) in listed { | |
| 92 | pages += 1; | |
| 93 | match Page::from_space(&document, space) { | |
| 94 | Ok(page) => { | |
| 95 | let ids = identities(&page); | |
| 96 | let distinct: BTreeSet<_> = ids.iter().collect(); | |
| 97 | if distinct.len() != ids.len() { | |
| 98 | failures.push(format!("{}: repeated identity", path.display())); | |
| 99 | } | |
| 100 | } | |
| 101 | Err(error) => failures.push(format!("{}: {}", path.display(), error.message)), | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | assert!(pages > 1000, "{pages} pages"); | |
| 106 | assert!( | |
| 107 | failures.is_empty(), | |
| 108 | "{}\n{} failures", | |
| 109 | failures.join("\n"), | |
| 110 | failures.len() | |
| 111 | ); | |
| 112 | } |