From e00506af62d3a184a53b6dec1b40ac6bd6372c3c Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 10 Sep 2026 21:08:05 -0700 Subject: [PATCH] feat: add the editable page model 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.1 --- crates/onestore/README.md | 2 + crates/onestore/src/commit.rs | 4 +- crates/onestore/src/lib.rs | 5 +- crates/onestore/src/page/mod.rs | 1276 +++++++++++++++++++++ crates/onestore/src/page/text.rs | 571 +++++++++ crates/onestore/src/{page.rs => pages.rs} | 52 +- crates/onestore/src/store.rs | 2 +- crates/onestore/tests/page_model.rs | 112 ++ 8 files changed, 1982 insertions(+), 42 deletions(-) create mode 100644 crates/onestore/src/page/mod.rs create mode 100644 crates/onestore/src/page/text.rs rename crates/onestore/src/{page.rs => pages.rs} (94%) create mode 100644 crates/onestore/tests/page_model.rs diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 0b014e81d2af402f96bf9b02bb1adac08b9a07c9..ef93bef9d338e686ca7db07adc29dfa8ad815902 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -43,6 +43,8 @@ harness also accepts `--client-profile release`. | `PropertySets`, `Object::references` | Decode properties and ID streams while retaining raw values | | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | +| `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 | +| `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` | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index c5f41bec49f49f0557d494e02073b84e0c80c92f..9d5e474b19a9bc60522d1e049ab473445d506135 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -188,7 +188,7 @@ impl<'a> PreparedEdit<'a> { pub fn pages(source: &'a [u8], edits: &[crate::PageEdit]) -> Result { Ok(Self { source, - written: crate::page::edit_pages(source, edits, &[])?, + written: crate::pages::edit_pages(source, edits, &[])?, }) } @@ -201,7 +201,7 @@ impl<'a> PreparedEdit<'a> { ) -> Result { Ok(Self { source, - written: crate::page::edit_pages(source, &[], pages)?, + written: crate::pages::edit_pages(source, &[], pages)?, }) } diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 5cf64bcba45d63834e44ce062111cbdea98775aa..9a4deb50081cd1c1bf9f4fe581e78727ca5e1ac8 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -12,7 +12,8 @@ mod formatting; mod insertion; mod objects; mod outline; -mod page; +pub mod page; +mod pages; mod paragraph; mod properties; #[cfg(feature = "protected")] @@ -36,7 +37,7 @@ pub use formatting::TextAttribute; pub use insertion::Insertion; pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; pub use outline::OutlineEdit; -pub use page::{PageCreation, PageEdit, PagePosition}; +pub use pages::{PageCreation, PageEdit, PagePosition}; pub use paragraph::{ParagraphJoin, ParagraphSplit}; pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..b2987c9bc23355b9258b02613a9979a1a55e857f --- /dev/null +++ b/crates/onestore/src/page/mod.rs @@ -0,0 +1,1276 @@ +//! An editable page model: outlines, paragraphs, tables and images with their stored +//! identities, plus everything else on the page retained as `Unsupported`. + +use crate::{ + Error, ExGuid, + document::{Document, Element, FieldValue, Format, Kind, Layout, Revision, Tag}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +pub mod text; +pub use text::Paragraph; + +/// The role of a title-outline paragraph that displays the page's creation date or time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DateField { + Date = 0, + Time = 1, +} + +pub struct Page { + pub title: String, + /// FILETIME ticks from the page's TopologyCreationTimeStamp. + pub created: Option, + pub margin_origin: [f32; 2], + pub objects: Vec, + pub definitions: BTreeMap, +} + +pub struct Definition { + pub kind: Kind<'static>, + pub format: Format, +} + +pub enum PageObject { + Outline(Outline), + Title(Title), + Image(Image), + Unsupported(Unsupported), +} + +impl PageObject { + pub fn id(&self) -> ExGuid { + match self { + Self::Outline(value) => value.id, + Self::Title(value) => value.id, + Self::Image(value) => value.id, + Self::Unsupported(value) => value.id, + } + } + pub fn layout(&self) -> &Layout { + match self { + Self::Outline(value) => &value.layout, + Self::Title(value) => &value.layout, + Self::Image(value) => &value.layout, + Self::Unsupported(value) => &value.layout, + } + } + + pub fn layout_mut(&mut self) -> &mut Layout { + match self { + Self::Outline(value) => &mut value.layout, + Self::Title(value) => &mut value.layout, + Self::Image(value) => &mut value.layout, + Self::Unsupported(value) => &mut value.layout, + } + } +} + +pub struct Title { + pub id: ExGuid, + pub date: Option, + pub layout: Layout, + pub outlines: Vec, +} + +#[derive(Clone)] +pub struct Outline { + pub id: ExGuid, + pub title: bool, + pub min_width: Option, + pub layout: Layout, + pub indents: Vec, + pub paragraphs: Vec, + pub unsupported: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PageParagraph { + pub id: ExGuid, + pub parent: Option, + pub level: u32, + pub style: Option, + pub format: Format, + pub content: ParagraphContent, + pub lists: Vec, + pub tags: Vec, + pub collapsed: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ParagraphContent { + Text(TextObject), + Table(Table), + Unsupported(Unsupported), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub id: ExGuid, + pub columns: Vec, + pub rows: Vec, + pub borders: Option, + pub layout: Layout, + pub tags: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableColumn { + pub width: f32, + pub locked: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableRow { + pub id: ExGuid, + pub cells: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TableCell { + pub id: ExGuid, + pub layout: Layout, + pub indents: Vec, + pub shading: Option, + pub paragraphs: Vec, + pub unsupported: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextObject { + pub id: ExGuid, + pub date_field: Option, + pub text: Paragraph, + pub tags: Vec, +} + +pub struct Image { + pub id: ExGuid, + pub layout: Layout, + pub bytes: Option>, + pub alt: Option, + pub background: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Unsupported { + pub id: ExGuid, + pub jcid: u32, + pub layout: Layout, +} + +impl Page { + pub fn from_document(document: &Document<'_>, title: &str) -> Result { + let mut selected = None; + for (space, id) in document.pages()? { + let revision = document.active(space)?; + if page_title(revision, id) == Some(title) { + if selected.is_some() { + return Err(Error { + offset: 0, + message: "More than one active page has the requested title", + }); + } + selected = Some((revision, id)); + } + } + let (revision, id) = selected.ok_or(Error { + offset: 0, + message: "No active page has the requested title", + })?; + Self::from_revision(revision, id) + } + + /// The single active page declared in one page object space. + pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result { + let pages = document.pages_in(space)?; + let [id] = pages.as_slice() else { + return Err(Error { + offset: 0, + message: "Choose an object space containing one active page", + }); + }; + Self::from_revision(document.active(space)?, *id) + } + + pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result { + let invalid = |message| Error { offset: 0, message }; + let root = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas page"))?; + let Kind::Page { + margin_origin_x, + margin_origin_y, + .. + } = &root.kind + else { + return Err(invalid("Canvas root is not a page")); + }; + let mut page = Self { + title: page_title(revision, id).unwrap_or_default().to_owned(), + created: revision + .roots + .get(&2) + .and_then(|id| revision.nodes.get(id)) + .filter(|node| matches!(node.kind, Kind::Metadata { .. })) + .and_then(|node| node.extra.first()) + .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65)) + .map(|field| { + let FieldValue::Bytes(bytes) = field.value else { + return Err(invalid("The page creation date is damaged")); + }; + Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| { + invalid("The page creation date is damaged") + })?)) + }) + .transpose()?, + margin_origin: [ + margin_origin_x.unwrap_or(0.0), + margin_origin_y.unwrap_or(0.0), + ], + objects: Vec::new(), + definitions: BTreeMap::new(), + }; + let mut roots: Vec<_> = root + .children + .iter() + .chain(&root.structure) + .rev() + .map(|id| (*id, None)) + .collect(); + let mut seen = BTreeSet::new(); + while let Some((id, title_index)) = roots.pop() { + if !seen.insert(id) { + return Err(invalid("Repeated canvas page object")); + } + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas page object"))?; + if title_index.is_some() && !matches!(node.kind, Kind::Outline { .. }) { + return Err(invalid("Canvas title child is not an outline")); + } + match &node.kind { + Kind::Title => { + let index = page.objects.len(); + page.objects.push(PageObject::Title(Title { + id, + date: None, + layout: node.layout.clone(), + outlines: Vec::new(), + })); + roots.extend(node.children.iter().rev().map(|id| (*id, Some(index)))); + } + Kind::Outline { indents } => { + let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default(); + if let Some(index) = title_index + && fields.iter().any(|field| { + field.id == 0x88001cb5 && matches!(field.value, FieldValue::NoData) + }) + { + let PageObject::Title(title) = &mut page.objects[index] else { + unreachable!() + }; + if title.date.replace(id).is_some() { + return Err(invalid("The page has more than one date field")); + } + } + let min_width = fields + .iter() + .find(|field| field.id == 0x14001cec) + .map(|field| { + let FieldValue::Bytes(bytes) = field.value else { + return Err(invalid("Invalid canvas minimum outline width")); + }; + let width = + f32::from_le_bytes(bytes.try_into().map_err(|_| { + invalid("Invalid canvas minimum outline width") + })?) * 36.0; + if !width.is_finite() || width <= 0.0 { + return Err(invalid("Invalid canvas minimum outline width")); + } + Ok(width) + }) + .transpose()?; + let mut outline = Outline { + id, + title: fields.iter().any(|field| { + field.id == 0x88001cb4 && matches!(field.value, FieldValue::NoData) + }), + min_width, + layout: node.layout.clone(), + indents: indents.clone(), + paragraphs: Vec::new(), + unsupported: Vec::new(), + }; + (outline.paragraphs, outline.unsupported) = read_paragraphs( + revision, + node, + &node.format, + &mut page.definitions, + &mut seen, + 0, + )?; + if let Some(index) = title_index { + let PageObject::Title(title) = &mut page.objects[index] else { + unreachable!() + }; + title.outlines.push(outline); + } else { + page.objects.push(PageObject::Outline(outline)); + } + } + Kind::Image { + container, + alt, + background, + .. + } => { + let bytes = if let Some(container) = container { + let data = revision + .nodes + .get(container) + .ok_or_else(|| invalid("Missing canvas image data"))?; + match &data.kind { + Kind::File { payload, .. } => payload.map(Arc::from), + _ => return Err(invalid("Canvas image data has the wrong type")), + } + } else { + None + }; + page.objects.push(PageObject::Image(Image { + id, + layout: node.layout.clone(), + bytes, + alt: alt.clone(), + background: background.unwrap_or(false), + })); + } + _ => page.objects.push(PageObject::Unsupported(Unsupported { + id, + jcid: node.jcid, + layout: node.layout.clone(), + })), + } + } + Ok(page) + } +} + +fn read_paragraphs( + revision: &Revision<'_>, + container: &Element<'_>, + inherited: &Format, + definitions: &mut BTreeMap, + seen: &mut BTreeSet, + depth: usize, +) -> Result<(Vec, Vec), Error> { + let invalid = |message| Error { offset: 0, message }; + let default_format = Format::default(); + let style = |id: Option| -> Result<&Format, Error> { + let Some(id) = id else { + return Ok(&default_format); + }; + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas paragraph style"))?; + if !matches!(node.kind, Kind::Style { .. }) { + return Err(invalid("Canvas paragraph style has the wrong type")); + } + Ok(&node.format) + }; + + let mut paragraphs = Vec::new(); + let mut unsupported = Vec::new(); + let mut pending: Vec<_> = container + .children + .iter() + .rev() + .map(|id| { + ( + *id, + None, + u32::from(container.child_level.unwrap_or(0)), + inherited.clone(), + ) + }) + .collect(); + while let Some((id, parent, level, inherited)) = pending.pop() { + if !seen.insert(id) { + return Err(invalid("Repeated canvas outline object")); + } + let node = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas outline object"))?; + let next_level = level + .checked_add(u32::from(node.child_level.unwrap_or(0))) + .ok_or_else(|| invalid("Canvas outline level overflow"))?; + match &node.kind { + Kind::Paragraph { + lists, + paragraph_style, + collapse_state, + } => { + let format = node + .format + .inherit(style(*paragraph_style)?) + .inherit(&inherited); + let mut base_style = *paragraph_style; + let [content_id] = node.content.as_slice() else { + return Err(invalid("A paragraph must contain one content object")); + }; + if !seen.insert(*content_id) { + return Err(invalid("Repeated canvas paragraph content")); + } + let content = revision + .nodes + .get(content_id) + .ok_or_else(|| invalid("Missing canvas paragraph content"))?; + let content = if let Kind::RichText { + paragraph_style, .. + } = &content.kind + { + base_style = paragraph_style.or(base_style); + let runs = revision.text_runs(*content_id)?; + let text_content = if runs.is_empty() { + Paragraph::new( + String::new(), + content + .format + .inherit(style(*paragraph_style)?) + .inherit(&format), + ) + } else { + Paragraph::from_runs( + runs.into_iter() + .map(|run| (run.text.to_owned(), run.format.inherit(&format))), + ) + }; + ParagraphContent::Text(TextObject { + id: *content_id, + date_field: content + .extra + .first() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter(|field| matches!(field.value, FieldValue::NoData)) + .filter_map(|field| match field.id { + 0x88001cb5 => Some(DateField::Date), + 0x88001c87 => Some(DateField::Time), + _ => None, + }) + .try_fold(None, |previous, field| { + if previous.is_some() { + Err(invalid("The page date field has conflicting roles")) + } else { + Ok(Some(field)) + } + })?, + text: text_content, + tags: content.tags.clone(), + }) + } else if matches!(content.kind, Kind::Table { .. }) { + ParagraphContent::Table(read_table( + revision, + *content_id, + &content.format.inherit(&format), + definitions, + seen, + depth + 1, + )?) + } else { + ParagraphContent::Unsupported(Unsupported { + id: *content_id, + jcid: content.jcid, + layout: content.layout.clone(), + }) + }; + for (id, is_list) in lists.iter().map(|id| (id, true)).chain( + node.tags + .iter() + .chain(match &content { + ParagraphContent::Text(text) => text.tags.as_slice(), + ParagraphContent::Table(table) => table.tags.as_slice(), + ParagraphContent::Unsupported(_) => &[], + }) + .filter_map(|tag| tag.definition.as_ref()) + .map(|id| (id, false)), + ) { + let definition = revision + .nodes + .get(id) + .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; + let kind = match &definition.kind { + Kind::List { + font, + format, + restart, + bullet, + } if is_list => Kind::List { + font: font.clone(), + format: format.clone(), + restart: *restart, + bullet: *bullet, + }, + Kind::TagDefinition { + label, + action_type, + shape, + color, + highlight, + } if !is_list => Kind::TagDefinition { + label: label.clone(), + action_type: *action_type, + shape: *shape, + color: *color, + highlight: *highlight, + }, + _ => { + return Err(invalid( + "Canvas list or tag definition has the wrong type", + )); + } + }; + definitions.insert( + *id, + Definition { + kind, + format: definition.format.clone(), + }, + ); + } + if let Some(id) = base_style { + let definition = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas paragraph style"))?; + let Kind::Style { name } = &definition.kind else { + return Err(invalid("Canvas paragraph style has the wrong type")); + }; + definitions.entry(id).or_insert_with(|| Definition { + kind: Kind::Style { name: name.clone() }, + format: definition.format.clone(), + }); + } + paragraphs.push(PageParagraph { + id, + parent, + level, + style: base_style, + format: format.clone(), + content, + lists: lists.clone(), + tags: node.tags.clone(), + collapsed: *collapse_state == Some(1), + }); + pending.extend( + node.children + .iter() + .rev() + .map(|child| (*child, Some(id), next_level, format.clone())), + ); + } + Kind::OutlineGroup => pending.extend( + node.children + .iter() + .rev() + .map(|child| (*child, parent, next_level, node.format.inherit(&inherited))), + ), + _ => unsupported.push(Unsupported { + id, + jcid: node.jcid, + layout: node.layout.clone(), + }), + } + } + Ok((paragraphs, unsupported)) +} + +fn read_table( + revision: &Revision<'_>, + id: ExGuid, + inherited: &Format, + definitions: &mut BTreeMap, + seen: &mut BTreeSet, + depth: usize, +) -> Result { + let invalid = |message| Error { offset: 0, message }; + if depth > 64 { + return Err(invalid("Tables are nested too deeply")); + } + let node = &revision.nodes[&id]; + let Kind::Table { + rows, + columns, + widths, + locked, + borders, + } = &node.kind + else { + unreachable!() + }; + if rows.map(|n| n as usize) != Some(node.children.len()) + || node.children.is_empty() + || columns.map(|n| n as usize) != Some(widths.len()) + || widths.is_empty() + || (!locked.is_empty() && locked.len() != widths.len()) + || widths.iter().any(|w| !w.is_finite() || *w < 36.0) + { + return Err(invalid("Table dimensions are inconsistent")); + } + let mut table_rows = Vec::new(); + for row_id in &node.children { + if !seen.insert(*row_id) { + return Err(invalid("Repeated canvas table row")); + } + let row = revision + .nodes + .get(row_id) + .ok_or_else(|| invalid("Missing canvas table row"))?; + if !matches!(row.kind, Kind::Row) || row.children.len() != widths.len() { + return Err(invalid("Table row dimensions are inconsistent")); + } + let mut cells = Vec::new(); + for cell_id in &row.children { + if !seen.insert(*cell_id) { + return Err(invalid("Repeated canvas table cell")); + } + let cell = revision + .nodes + .get(cell_id) + .ok_or_else(|| invalid("Missing canvas table cell"))?; + if cell.children.is_empty() { + return Err(invalid("A table cell has no paragraphs")); + } + let Kind::Cell { shading, indents } = &cell.kind else { + return Err(invalid("A table row contains an invalid cell")); + }; + let (paragraphs, unsupported) = read_paragraphs( + revision, + cell, + &cell.format.inherit(inherited), + definitions, + seen, + depth, + )?; + cells.push(TableCell { + id: *cell_id, + layout: cell.layout.clone(), + indents: indents.clone(), + shading: *shading, + paragraphs, + unsupported, + }); + } + table_rows.push(TableRow { id: *row_id, cells }); + } + Ok(Table { + id, + columns: widths + .iter() + .enumerate() + .map(|(index, &width)| TableColumn { + width, + locked: locked.get(index).copied().unwrap_or(false), + }) + .collect(), + rows: table_rows, + borders: *borders, + layout: node.layout.clone(), + tags: node.tags.clone(), + }) +} + +fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> { + revision + .roots + .get(&2) + .and_then(|id| revision.nodes.get(id)) + .and_then(|node| { + if let Kind::Metadata { title, .. } = &node.kind { + title.as_deref() + } else { + None + } + }) + .or_else(|| { + if let Kind::Page { + alternate_title, .. + } = &revision.nodes.get(&id)?.kind + { + alternate_title.as_deref() + } else { + None + } + }) +} + +impl PageParagraph { + pub fn text(&self) -> Option<&TextObject> { + match &self.content { + ParagraphContent::Text(text) => Some(text), + _ => None, + } + } + pub fn text_mut(&mut self) -> Option<&mut TextObject> { + match &mut self.content { + ParagraphContent::Text(text) => Some(text), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::{Element, TextRun}; + + fn id(n: u32) -> ExGuid { + ExGuid { + n, + ..ExGuid::default() + } + } + + fn element(kind: Kind<'_>) -> Element<'_> { + Element { + jcid: 0, + children: Vec::new(), + content: Vec::new(), + structure: Vec::new(), + spaces: Vec::new(), + child_level: None, + layout: Layout::default(), + format: Format::default(), + created: None, + modified: None, + original_author: None, + latest_author: None, + media_ids: Vec::new(), + media_time_ms: None, + tags: Vec::new(), + kind, + extra: Vec::new(), + } + } + + fn revision() -> Revision<'static> { + let mut page = element(Kind::Page { + alternate_title: Some("Fallback".into()), + level: None, + width: None, + height: None, + margin_origin_x: Some(36.0), + margin_origin_y: Some(12.0), + rtl: None, + }); + page.children.push(id(2)); + let mut outline = element(Kind::Outline { + indents: vec![18.0, 0.0, 36.0], + }); + outline.children.push(id(3)); + outline.child_level = Some(1); + outline.format.font_size = Some(9.0); + outline.format.bold = Some(true); + let mut paragraph = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: Some(id(5)), + collapse_state: Some(1), + }); + paragraph.content.push(id(4)); + paragraph.format.bold = Some(false); + let text = element(Kind::RichText { + text: "ab".into(), + runs: vec![TextRun { + start: 0, + end: 2, + format: None, + extra_set: None, + }], + paragraph_style: None, + boilerplate: false, + }); + let mut style = element(Kind::Style { + name: Some("Body".into()), + }); + style.format.font_size = Some(12.0); + let metadata = element(Kind::Metadata { + title: Some("Page title".into()), + level: None, + }); + Revision { + roots: BTreeMap::from([(2, id(6))]), + nodes: BTreeMap::from([ + (id(1), page), + (id(2), outline), + (id(3), paragraph), + (id(4), text), + (id(5), style), + (id(6), metadata), + ]), + } + } + + fn table_revision() -> Revision<'static> { + let mut source = revision(); + source.nodes.get_mut(&id(3)).unwrap().content = vec![id(7)]; + let mut table = element(Kind::Table { + rows: Some(1), + columns: Some(2), + widths: vec![37.11, 99.0], + locked: vec![false, true], + borders: Some(false), + }); + table.children = vec![id(8)]; + let mut row = element(Kind::Row); + row.children = vec![id(9), id(10)]; + source.nodes.insert(id(7), table); + source.nodes.insert(id(8), row); + for (cell, paragraph, text) in [(9, 11, 4), (10, 12, 13)] { + let mut node = element(Kind::Cell { + shading: Some(0x00ffff), + indents: vec![18.0, 0.0, 27.0, 27.0], + }); + node.child_level = Some(1); + node.layout.max_width = Some(268.8); + node.children = vec![id(paragraph)]; + if cell == 10 { + node.format.font_size = Some(13.0); + } + source.nodes.insert(id(cell), node); + let mut node = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + node.content = vec![id(text)]; + source.nodes.insert(id(paragraph), node); + } + source.nodes.insert( + id(13), + element(Kind::RichText { + text: "cd".into(), + runs: vec![TextRun { + start: 0, + end: 2, + format: None, + extra_set: None, + }], + paragraph_style: None, + boilerplate: false, + }), + ); + source + } + + #[test] + fn table_import_owns_cells_and_inherits_format_across_containers() { + let page = { + let source = table_revision(); + let before = serde_json::to_vec(&source).unwrap(); + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(serde_json::to_vec(&source).unwrap(), before); + page + }; + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { + panic!() + }; + assert_eq!(table.id, id(7)); + assert_eq!(table.borders, Some(false)); + assert_eq!( + table.columns, + [ + TableColumn { + width: 37.11, + locked: false + }, + TableColumn { + width: 99.0, + locked: true + } + ] + ); + assert_eq!(table.rows[0].id, id(8)); + for (index, cell) in table.rows[0].cells.iter().enumerate() { + assert_eq!(cell.id, id(9 + index as u32)); + assert_eq!(cell.layout.max_width, Some(268.8)); + assert_eq!(cell.indents, [18.0, 0.0, 27.0, 27.0]); + assert_eq!(cell.shading, Some(0x00ffff)); + let paragraph = &cell.paragraphs[0]; + assert_eq!(paragraph.parent, None); + assert_eq!(paragraph.level, 1); + assert_eq!(paragraph.id, id(11 + index as u32)); + let text = paragraph.text().unwrap(); + assert_eq!(text.id, if index == 0 { id(4) } else { id(13) }); + assert_eq!(text.text.text(), if index == 0 { "ab" } else { "cd" }); + assert_eq!( + text.text.spans()[0].format.font_size, + Some(if index == 0 { 12.0 } else { 13.0 }) + ); + assert_eq!(text.text.spans()[0].format.bold, Some(false)); + } + } + + #[test] + fn absent_column_locks_use_the_specified_unlocked_default() { + let mut source = table_revision(); + let Kind::Table { locked, .. } = &mut source.nodes.get_mut(&id(7)).unwrap().kind else { + panic!() + }; + locked.clear(); + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + let ParagraphContent::Table(table) = &outline.paragraphs[0].content else { + panic!() + }; + assert!(table.columns.iter().all(|column| !column.locked)); + } + + #[test] + fn table_import_rejects_aliases_cycles_and_inconsistent_dimensions() { + for case in 0..7 { + let mut source = table_revision(); + match case { + 0 => source.nodes.get_mut(&id(8)).unwrap().children[1] = id(9), + 1 => source.nodes.get_mut(&id(11)).unwrap().content = vec![id(7)], + 2 => source.nodes.get_mut(&id(12)).unwrap().content = vec![id(4)], + 3 => source.nodes.get_mut(&id(3)).unwrap().content.push(id(4)), + 4 => source.nodes.get_mut(&id(9)).unwrap().children.clear(), + 5 => { + let Kind::Table { columns, .. } = + &mut source.nodes.get_mut(&id(7)).unwrap().kind + else { + panic!() + }; + *columns = Some(3); + } + _ => { + let Kind::Table { widths, .. } = + &mut source.nodes.get_mut(&id(7)).unwrap().kind + else { + panic!() + }; + widths[0] = f32::INFINITY; + } + } + assert!(Page::from_revision(&source, id(1)).is_err(), "case {case}"); + } + } + + #[test] + fn nested_tables_import_with_a_bounded_depth() { + for levels in [1, 62, 65] { + let mut source = table_revision(); + let mut paragraph = id(11); + for depth in 0..levels { + let base = 20 + depth * 4; + source.nodes.get_mut(¶graph).unwrap().content = vec![id(base)]; + let mut table = element(Kind::Table { + rows: Some(1), + columns: Some(1), + widths: vec![72.0], + locked: vec![true], + borders: Some(true), + }); + table.children = vec![id(base + 1)]; + let mut row = element(Kind::Row); + row.children = vec![id(base + 2)]; + let mut cell = element(Kind::Cell { + shading: None, + indents: vec![18.0, 0.0, 27.0, 27.0], + }); + cell.child_level = Some(1); + cell.children = vec![id(base + 3)]; + let mut child = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + child.content = vec![id(4)]; + source.nodes.extend([ + (id(base), table), + (id(base + 1), row), + (id(base + 2), cell), + (id(base + 3), child), + ]); + paragraph = id(base + 3); + } + assert_eq!(Page::from_revision(&source, id(1)).is_ok(), levels < 64); + } + } + + #[test] + fn owns_page_content_and_resolves_style_before_ancestor_defaults() { + let page = { + let mut source = revision(); + source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag { + definition: Some(id(7)), + action_type: None, + status: 0, + created: Some(123), + completed: None, + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + source.nodes.insert( + id(7), + element(Kind::TagDefinition { + label: Some("To Do".into()), + action_type: Some(0), + shape: Some(3), + color: None, + highlight: None, + }), + ); + let before = serde_json::to_vec(&source).unwrap(); + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(before, serde_json::to_vec(&source).unwrap()); + page + }; + assert_eq!(page.title, "Page title"); + assert_eq!(page.margin_origin, [36.0, 12.0]); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + assert_eq!(outline.paragraphs.len(), 1); + let paragraph = &outline.paragraphs[0]; + assert!(paragraph.collapsed); + assert_eq!(paragraph.level, 1); + assert_eq!(paragraph.parent, None); + assert_eq!(paragraph.style, Some(id(5))); + assert!(matches!(&page.definitions[&id(5)].kind, + Kind::Style { name: Some(name) } if name == "Body")); + assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0)); + let text = ¶graph.text().unwrap().text; + assert_eq!(text.text(), "ab"); + assert_eq!(text.spans()[0].format.font_size, Some(12.0)); + assert_eq!(text.spans()[0].format.bold, Some(false)); + assert_eq!(paragraph.text().unwrap().tags[0].created, Some(123)); + assert!(matches!(&page.definitions[&id(7)].kind, + Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do")); + } + + #[test] + fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() { + use crate::document::{Field, FieldValue}; + let mut source = revision(); + source.nodes.get_mut(&id(1)).unwrap().created = Some(7); + let bytes = 134_333_468_649_123_456_u64.to_le_bytes(); + source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field { + id: 0x18001c65, + value: FieldValue::Bytes(&bytes), + }]]; + source.nodes.get_mut(&id(1)).unwrap().children.clear(); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + let mut title = element(Kind::Title); + title.children.push(id(2)); + source.nodes.insert(id(7), title); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![Field { + id: 0x88001cb5, + value: FieldValue::NoData, + }]]; + source.nodes.get_mut(&id(4)).unwrap().extra = vec![vec![Field { + id: 0x88001c87, + value: FieldValue::NoData, + }]]; + let page = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!(page.created, Some(134_333_468_649_123_456)); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert_eq!(title.date, Some(id(2))); + assert_eq!( + title.outlines[0].paragraphs[0].text().unwrap().date_field, + Some(DateField::Time) + ); + source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(6)).unwrap().extra.clear(); + assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None); + } + + #[test] + fn title_role_and_minimum_width_come_from_root_properties() { + use crate::document::{Field, FieldValue}; + let mut source = revision(); + source.nodes.get_mut(&id(1)).unwrap().children.clear(); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + let mut title = element(Kind::Title); + title.children.push(id(2)); + source.nodes.insert(id(7), title); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![ + Field { + id: 0x88001cb4, + value: FieldValue::NoData, + }, + Field { + id: 0x14001cec, + value: FieldValue::Bytes(&[0, 0, 0x90, 0x40]), + }, + ]]; + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert!(title.outlines[0].title); + assert_eq!(title.outlines[0].min_width, Some(162.0)); + source.nodes.get_mut(&id(2)).unwrap().extra[0][1].value = + FieldValue::Bytes(&[0, 0, 0x80, 0x7f]); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(2)).unwrap().extra = vec![ + vec![Field { + id: 0x08001cb4, + value: FieldValue::NoData, + }], + vec![Field { + id: 0x88001cb4, + value: FieldValue::NoData, + }], + ]; + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Title(title) = &page.objects[0] else { + panic!() + }; + assert!(!title.outlines[0].title); + assert_eq!(title.outlines[0].min_width, None); + } + + #[test] + fn preserves_paint_order_nested_parents_and_owned_image_payloads() { + let bytes = vec![1, 2, 3, 4]; + let mut source = revision(); + let image = element(Kind::Image { + container: Some(id(8)), + filename: None, + alt: Some("Image".into()), + picture_width: None, + picture_height: None, + background: Some(true), + printout: None, + link: None, + }); + let file = element(Kind::File { + reference: crate::FileDataReference::Internal([0; 16]), + extension: "png".into(), + payload: Some(bytes.as_slice()), + }); + let mut title = element(Kind::Title); + title.layout.x = Some(12.0); + title.layout.y = Some(24.0); + title.children.push(id(10)); + let title_outline = element(Kind::Outline { + indents: Vec::new(), + }); + let mut group = element(Kind::OutlineGroup); + group.children.push(id(12)); + group.child_level = Some(1); + let mut child = element(Kind::Paragraph { + lists: Vec::new(), + paragraph_style: None, + collapse_state: None, + }); + child.content.push(id(13)); + source.nodes.insert( + id(13), + element(Kind::RichText { + text: "".into(), + runs: Vec::new(), + paragraph_style: None, + boilerplate: false, + }), + ); + source.nodes.extend([ + (id(7), image), + (id(8), file), + (id(9), title), + (id(10), title_outline), + (id(11), group), + (id(12), child), + ]); + source + .nodes + .get_mut(&id(1)) + .unwrap() + .children + .insert(0, id(7)); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(9)); + source.nodes.get_mut(&id(3)).unwrap().children.push(id(11)); + source.nodes.get_mut(&id(3)).unwrap().child_level = Some(1); + let page = Page::from_revision(&source, id(1)).unwrap(); + drop(source); + drop(bytes); + assert_eq!(page.objects.len(), 3); + let PageObject::Image(image) = &page.objects[0] else { + panic!() + }; + assert_eq!(image.bytes.as_deref(), Some([1, 2, 3, 4].as_slice())); + let PageObject::Outline(outline) = &page.objects[1] else { + panic!() + }; + assert_eq!(outline.paragraphs[1].id, id(12)); + assert_eq!(outline.paragraphs[1].parent, Some(id(3))); + assert_eq!(outline.paragraphs[1].level, 3); + let PageObject::Title(title) = &page.objects[2] else { + panic!() + }; + assert_eq!(title.id, id(9)); + assert_eq!([title.layout.x, title.layout.y], [Some(12.0), Some(24.0)]); + assert_eq!(title.outlines[0].id, id(10)); + assert_eq!(title.outlines[0].layout.x, None); + } + + #[test] + fn rejects_title_children_that_are_not_outlines() { + let mut source = revision(); + let mut title = element(Kind::Title); + title.children.push(id(8)); + source.nodes.insert(id(7), title); + source.nodes.insert(id(8), element(Kind::Title)); + source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); + assert_eq!( + Page::from_revision(&source, id(1)).err().unwrap().message, + "Canvas title child is not an outline" + ); + } + + #[test] + fn validates_empty_text_styles_missing_references_and_cycles() { + let mut source = revision(); + source.nodes.get_mut(&id(4)).unwrap().kind = Kind::RichText { + text: String::new(), + runs: Vec::new(), + paragraph_style: Some(id(5)), + boilerplate: false, + }; + source.nodes.get_mut(&id(3)).unwrap().format.font_size = Some(10.0); + let page = Page::from_revision(&source, id(1)).unwrap(); + let PageObject::Outline(outline) = &page.objects[0] else { + panic!() + }; + assert_eq!( + outline.paragraphs[0].text().unwrap().text.spans()[0] + .format + .font_size, + Some(12.0) + ); + source.nodes.get_mut(&id(3)).unwrap().children.push(id(2)); + assert!(Page::from_revision(&source, id(1)).is_err()); + source.nodes.get_mut(&id(3)).unwrap().children.clear(); + source.nodes.remove(&id(5)); + assert!(Page::from_revision(&source, id(1)).is_err()); + } +} diff --git a/crates/onestore/src/page/text.rs b/crates/onestore/src/page/text.rs new file mode 100644 index 0000000000000000000000000000000000000000..1703e4b6806b65d398551f2ac7bbbc92cf2c730e --- /dev/null +++ b/crates/onestore/src/page/text.rs @@ -0,0 +1,571 @@ +use crate::{ExGuid, document::Format}; +use std::{fmt, ops::Range}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Span { + /// Exclusive UTF-8 boundary; the start is the preceding span's end. + pub end: usize, + pub format: Format, +} + +#[derive(Clone, Debug, PartialEq)] +/// Editable text styles are coalesced independently of serialized run boundaries. +pub struct Paragraph { + text: String, + spans: Vec, +} + +/// Which side of a hidden field a visible boundary maps to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Affinity { + Upstream, + Downstream, +} + +#[derive(Clone)] +pub struct TextProjection { + text: Paragraph, + spans: Vec, + source_len: u32, +} + +#[derive(Clone)] +struct ProjectedSpan { + visible: Range, + source_start: u32, +} + +impl TextProjection { + pub fn text(&self) -> &Paragraph { + &self.text + } + + /// Maps visible UTF-8 boundaries to downstream source UTF-16 positions in one pass. + pub fn source_boundaries(&self) -> impl Iterator + '_ { + let mut spans = self.spans.iter().peekable(); + let mut visible = 0; + self.text + .text + .char_indices() + .map(move |(byte, character)| { + while spans.peek().is_some_and(|span| span.visible.end <= visible) { + spans.next(); + } + let span = spans.peek().unwrap(); + let source = span.source_start + (visible - span.visible.start); + visible += character.len_utf16() as u32; + (byte, source) + }) + .chain(std::iter::once((self.text.text.len(), self.source_len))) + } + + pub fn source_offset(&self, visible: u32, affinity: Affinity) -> Result { + self.text.byte_offset(visible)?; + Ok(match affinity { + Affinity::Downstream => self + .spans + .iter() + .find(|span| span.visible.end > visible) + .map(|span| span.source_start + (visible - span.visible.start)) + .unwrap_or(self.source_len), + Affinity::Upstream => self + .spans + .iter() + .rev() + .find(|span| span.visible.start < visible) + .map(|span| span.source_start + (visible - span.visible.start)) + .unwrap_or(0), + }) + } + + /// Hidden source positions collapse to their visible boundary. + pub fn visible_offset(&self, source: u32) -> Result { + if source > self.source_len { + return Err(EditError::InvalidRange); + } + for span in &self.spans { + if source < span.source_start { + return Ok(span.visible.start); + } + let length = span.visible.end - span.visible.start; + if source < span.source_start + length { + let visible = span.visible.start + source - span.source_start; + self.text.byte_offset(visible)?; + return Ok(visible); + } + } + self.text.utf16_offset(self.text.text.len()) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Edit { + pub range: Range, + pub replacement: Paragraph, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditError { + InvalidRange, + TextTooLong, + InvalidStructure, + UnsupportedContent, + /// The system random source failed while allocating a new identity. + Identity, +} + +impl fmt::Display for EditError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Identity => "System random source failed", + Self::UnsupportedContent => { + "This paragraph contains content the text editor cannot edit" + } + Self::InvalidRange => "Text range is outside the paragraph or splits a surrogate pair", + Self::TextTooLong => "Text exceeds the UTF-16 offset range", + Self::InvalidStructure => { + "This outline has duplicate objects or broken paragraph links" + } + }) + } +} + +impl std::error::Error for EditError {} + +/// A fresh random identity for a new paragraph or text object. +pub fn new_id() -> Result { + Ok(ExGuid { + guid: crate::write::fresh_guid().map_err(|_| EditError::Identity)?, + n: 0, + }) +} + +impl Paragraph { + pub fn project(&self) -> Result { + let mut runs = Vec::new(); + let mut spans: Vec = Vec::new(); + let mut byte = 0; + let mut source = 0_u32; + let mut visible = 0_u32; + for span in &self.spans { + let fragment = &self.text[byte..span.end]; + let length: u32 = fragment + .encode_utf16() + .count() + .try_into() + .map_err(|_| EditError::TextTooLong)?; + let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?; + if span.format.hidden != Some(true) { + runs.push((fragment.replace('\u{000b}', "\n"), span.format.clone())); + if length > 0 { + if let Some(last) = spans.last_mut() + && last.source_start + (last.visible.end - last.visible.start) == source + { + last.visible.end += length; + } else { + spans.push(ProjectedSpan { + visible: visible..visible + length, + source_start: source, + }); + } + visible += length; + } + } + byte = span.end; + source = source_end; + } + if runs.is_empty() { + let mut format = self.spans[0].format.clone(); + format.hidden = Some(false); + runs.push((String::new(), format)); + } + Ok(TextProjection { + text: Self::from_runs(runs), + spans, + source_len: source, + }) + } + + pub fn new(text: String, format: Format) -> Self { + let end = text.len(); + Self { + text, + spans: vec![Span { end, format }], + } + } + + pub fn from_runs(runs: impl IntoIterator) -> Self { + let mut text = String::new(); + let mut spans: Vec = Vec::new(); + for (fragment, format) in runs { + text.push_str(&fragment); + if let Some(last) = spans.last_mut() { + if last.format == format { + last.end = text.len(); + continue; + } + if fragment.is_empty() && !text.is_empty() { + continue; + } + if last.end == 0 { + spans.clear(); + } + } + spans.push(Span { + end: text.len(), + format, + }); + } + if spans.is_empty() { + spans.push(Span { + end: 0, + format: Format::default(), + }); + } + Self { text, spans } + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn spans(&self) -> &[Span] { + &self.spans + } + + pub fn byte_offset(&self, utf16: u32) -> Result { + let mut units = 0_u64; + for (byte, character) in self.text.char_indices() { + if units == u64::from(utf16) { + return Ok(byte); + } + units += character.len_utf16() as u64; + if units > u64::from(utf16) { + return Err(EditError::InvalidRange); + } + } + if units == u64::from(utf16) { + Ok(self.text.len()) + } else { + Err(EditError::InvalidRange) + } + } + + pub fn utf16_offset(&self, byte: usize) -> Result { + self.text + .get(..byte) + .ok_or(EditError::InvalidRange)? + .encode_utf16() + .count() + .try_into() + .map_err(|_| EditError::TextTooLong) + } + + pub fn format_at(&self, utf16: u32) -> Result<&Format, EditError> { + let byte = self.byte_offset(utf16)?; + let index = self.spans.partition_point(|span| span.end < byte); + Ok(&self.spans[index].format) + } + + pub fn slice(&self, range: Range) -> Result { + if range.start > range.end { + return Err(EditError::InvalidRange); + } + let start = self.byte_offset(range.start)?; + let end = self.byte_offset(range.end)?; + if start == end { + return Ok(Self::new( + String::new(), + self.format_at(range.start)?.clone(), + )); + } + let mut previous = 0; + let mut spans = Vec::new(); + for span in &self.spans { + if previous < end && span.end > start { + spans.push(Span { + end: span.end.min(end) - start, + format: span.format.clone(), + }); + } + previous = span.end; + } + Ok(Self { + text: self.text[start..end].to_owned(), + spans, + }) + } + + /// Returns the inverse edit; applying that inverse returns a redo operation. + pub fn apply(&mut self, edit: Edit) -> Result { + let end = self.utf16_offset(self.text.len())?; + let removed = self.slice(edit.range.clone())?; + let inserted = edit.replacement.utf16_offset(edit.replacement.text.len())?; + let new_end = edit + .range + .start + .checked_add(inserted) + .ok_or(EditError::TextTooLong)?; + let resulting_len = end + .checked_sub(edit.range.end - edit.range.start) + .and_then(|n| n.checked_add(inserted)) + .ok_or(EditError::TextTooLong)?; + if resulting_len == 0 { + *self = edit.replacement; + return Ok(Edit { + range: edit.range.start..new_end, + replacement: removed, + }); + } + let prefix = self.slice(0..edit.range.start)?; + let suffix = self.slice(edit.range.end..end)?; + let mut runs = Vec::new(); + for part in [prefix, edit.replacement, suffix] { + let mut start = 0; + for span in part.spans { + if span.end > start { + runs.push((part.text[start..span.end].to_owned(), span.format)); + } + start = span.end; + } + } + *self = Self::from_runs(runs); + Ok(Edit { + range: edit.range.start..new_end, + replacement: removed, + }) + } + + pub fn append(&mut self, other: Self) -> Result { + let end = self.utf16_offset(self.text.len())?; + self.apply(Edit { + range: end..end, + replacement: other, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn regular() -> Format { + Format { + font: Some("Arial".into()), + font_size: Some(11.0), + ..Format::default() + } + } + + fn bold() -> Format { + Format { + bold: Some(true), + ..regular() + } + } + #[test] + fn soft_breaks_keep_source_offsets_and_formatting() { + let source = Paragraph::from_runs([("A\u{000b}".into(), regular()), ("🌳".into(), bold())]); + let projection = source.project().unwrap(); + assert_eq!(source.text(), "A\u{000b}🌳"); + assert_eq!(projection.text().text(), "A\n🌳"); + assert_eq!(projection.text().spans(), source.spans()); + assert_eq!( + projection.source_boundaries().collect::>(), + [(0, 0), (1, 1), (2, 2), (6, 4)] + ); + for offset in [0, 1, 2, 4] { + assert_eq!(projection.visible_offset(offset), Ok(offset)); + assert_eq!( + projection.source_offset(offset, Affinity::Downstream), + Ok(offset) + ); + } + assert!(projection.visible_offset(3).is_err()); + } + + #[test] + fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() { + let hidden = Format { + hidden: Some(true), + ..regular() + }; + let source = Paragraph::from_runs([ + ("A".into(), regular()), + ("🌳".into(), hidden.clone()), + ("e\u{301}".into(), bold()), + ("X".into(), hidden), + ]); + let original = source.clone(); + let projection = source.project().unwrap(); + assert_eq!(projection.text().text(), "Ae\u{301}"); + assert_eq!( + projection.source_boundaries().collect::>(), + [(0, 0), (1, 3), (2, 4), (4, 6)] + ); + for (visible, upstream, downstream) in [(0, 0, 0), (1, 1, 3), (2, 4, 4), (3, 5, 6)] { + assert_eq!( + projection.source_offset(visible, Affinity::Upstream), + Ok(upstream) + ); + assert_eq!( + projection.source_offset(visible, Affinity::Downstream), + Ok(downstream) + ); + } + for (source, visible) in [(0, 0), (1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3)] { + assert_eq!(projection.visible_offset(source), Ok(visible)); + } + assert_eq!(source, original); + } + + #[test] + fn projection_validates_visible_surrogates_and_preserves_empty_field_boundaries() { + let visible = Paragraph::new("a🌳z".into(), regular()).project().unwrap(); + assert_eq!( + visible.source_offset(2, Affinity::Downstream), + Err(EditError::InvalidRange) + ); + assert_eq!(visible.visible_offset(2), Err(EditError::InvalidRange)); + let hidden = Paragraph::new( + "🌳".into(), + Format { + hidden: Some(true), + ..regular() + }, + ) + .project() + .unwrap(); + assert!(hidden.text().text().is_empty()); + assert_eq!(hidden.source_boundaries().collect::>(), [(0, 2)]); + assert_eq!( + visible.source_boundaries().collect::>(), + [(0, 0), (1, 1), (5, 3), (6, 4)] + ); + assert_eq!(hidden.source_offset(0, Affinity::Upstream), Ok(0)); + assert_eq!(hidden.source_offset(0, Affinity::Downstream), Ok(2)); + assert_eq!(hidden.visible_offset(1), Ok(0)); + assert_eq!(hidden.visible_offset(3), Err(EditError::InvalidRange)); + } + + #[test] + fn offsets_distinguish_bytes_utf16_and_scalars() { + let text = Paragraph::new("a🌳e\u{301}".into(), regular()); + for (utf16, byte) in [(0, 0), (1, 1), (3, 5), (4, 6), (5, 8)] { + assert_eq!(text.byte_offset(utf16), Ok(byte)); + assert_eq!(text.utf16_offset(byte), Ok(utf16)); + } + assert_eq!(text.byte_offset(2), Err(EditError::InvalidRange)); + assert_eq!(text.utf16_offset(2), Err(EditError::InvalidRange)); + assert_eq!(text.byte_offset(6), Err(EditError::InvalidRange)); + assert_eq!(text.utf16_offset(9), Err(EditError::InvalidRange)); + } + + #[test] + fn replacement_preserves_styles_outside_selection() { + let mut text = Paragraph::from_runs([ + ("plain ".into(), regular()), + ("bold".into(), bold()), + (" end".into(), regular()), + ]); + let original = text.clone(); + let edit = Edit { + range: 4..8, + replacement: Paragraph::new("🌳".into(), bold()), + }; + let undo = text.apply(edit).unwrap(); + assert_eq!(text.text(), "plai🌳ld end"); + assert_eq!( + text.spans() + .iter() + .map(|s| (s.end, s.format.bold)) + .collect::>(), + [(4, None), (10, Some(true)), (14, None)] + ); + let after = text.clone(); + let redo = text.apply(undo).unwrap(); + assert_eq!(text, original); + text.apply(redo).unwrap(); + assert_eq!(text, after); + } + + #[test] + fn invalid_edits_leave_text_and_styles_unchanged() { + let original = Paragraph::new("a🌳b".into(), regular()); + for (start, end) in [(2, 3), (0, 2), (3, 2), (0, 5)] { + let mut text = original.clone(); + assert_eq!( + text.apply(Edit { + range: start..end, + replacement: Paragraph::new("x".into(), bold()) + }), + Err(EditError::InvalidRange) + ); + assert_eq!(text, original); + } + } + + #[test] + fn undo_restores_empty_paragraph_format() { + let mut text = Paragraph::new(String::new(), regular()); + let original = text.clone(); + let undo = text + .apply(Edit { + range: 0..0, + replacement: Paragraph::new("bold".into(), bold()), + }) + .unwrap(); + text.apply(undo).unwrap(); + assert_eq!(text, original); + } + + #[test] + fn every_scalar_range_round_trips_styled_unicode_edits() { + let original = Paragraph::from_runs([ + ("a🌳".into(), regular()), + ("e\u{301}Χ©ΧœΧ•Χ".into(), bold()), + ("Z".into(), regular()), + ]); + let boundaries: Vec<_> = original + .text() + .char_indices() + .map(|(n, _)| n) + .chain([original.text().len()]) + .collect(); + for &start in &boundaries { + for &end in boundaries.iter().filter(|&&end| end >= start) { + let range = + original.utf16_offset(start).unwrap()..original.utf16_offset(end).unwrap(); + for replacement in ["", "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦", "xyz", "\u{301}"] { + let mut edited = original.clone(); + let undo = edited + .apply(Edit { + range: range.clone(), + replacement: Paragraph::new(replacement.into(), bold()), + }) + .unwrap(); + assert_eq!( + edited.text(), + format!( + "{}{replacement}{}", + &original.text()[..start], + &original.text()[end..] + ) + ); + let after = edited.clone(); + let redo = edited.apply(undo).unwrap(); + assert_eq!(edited, original); + edited.apply(redo).unwrap(); + assert_eq!(edited, after); + } + } + let at = original.utf16_offset(start).unwrap(); + let mut left = original.slice(0..at).unwrap(); + let right = original + .slice(at..original.utf16_offset(original.text.len()).unwrap()) + .unwrap(); + left.append(right).unwrap(); + assert_eq!(left, original); + } + } +} diff --git a/crates/onestore/src/page.rs b/crates/onestore/src/pages.rs similarity index 94% rename from crates/onestore/src/page.rs rename to crates/onestore/src/pages.rs index 0337065751d4c363d2af759084a129be8322aec2..c007c99357976a1389251c94f053376c530e53bd 100644 --- a/crates/onestore/src/page.rs +++ b/crates/onestore/src/pages.rs @@ -156,8 +156,7 @@ pub(crate) fn edit_pages( write_revisions(source, |index| { let document = Document::parse(index)?; let pages = document.pages()?; - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + let view = document.active(document.root)?; let section_id = view.roots[&1]; let section_node = &view.nodes[§ion_id]; if !matches!(section_node.kind, Kind::Section { .. }) @@ -171,8 +170,7 @@ pub(crate) fn edit_pages( let mut levels = BTreeMap::new(); let mut page_guids = BTreeMap::new(); for (sid, _) in &pages { - let page = &document.spaces[sid]; - let page = &page.revisions[&page.contexts[&ExGuid::default()]]; + let page = document.active(*sid)?; let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { return Err(invalid("Page metadata is unavailable")); }; @@ -277,7 +275,7 @@ pub(crate) fn edit_pages( if order == original_order && levels == original_levels { return Ok(BTreeMap::new()); } - let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; + let raw = index.resolve_active(document.root)?; let mut groups: Vec> = Vec::new(); for sid in order { if levels[&sid] == 1 { @@ -287,7 +285,7 @@ pub(crate) fn edit_pages( } let mut changes = BTreeMap::new(); for sid in &removed { - let page = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; + let page = index.resolve_active(*sid)?; let manifest_id = page.roots[&1]; let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?; if manifest.jcid != 0x60037 { @@ -347,8 +345,7 @@ pub(crate) fn edit_pages( } let mut metadata_ids = Vec::new(); for sid in &spaces { - let page = - index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; + let page = index.resolve_active(*sid)?; let page_id = page.roots[&2]; let original = PropertyObject::from_object(&page.objects[&page_id])?; if old_id.is_none() && *sid == head { @@ -489,8 +486,7 @@ impl PageCreation { } let document = Document::parse(index)?; document.pages()?; - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + let view = document.active(document.root)?; let section_id = view.roots[&1]; let section_node = &view.nodes[§ion_id]; if !matches!(section_node.kind, Kind::Section { .. }) @@ -510,7 +506,7 @@ impl PageCreation { invalid("Insert before the first page of an existing series, or append") })?, }; - let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; + let raw = index.resolve_active(document.root)?; let id = |n| ExGuid { guid: self.guid, n }; let reference = |n: u32| n.to_le_bytes().to_vec(); let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) @@ -665,17 +661,13 @@ mod tests { let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let sid = document.pages().unwrap()[3].0; - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + let view = document.active(document.root).unwrap(); let series_id = view.nodes[&view.roots[&1]].children[3]; let copy_id = metadata_id(sid); for unknown in [false, true] { let source = if unknown { write_revisions(source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; + let raw = index.resolve_active(index.root)?; let mut copy = PropertyObject::from_object(&raw.objects[©_id])?; copy.set(&[(0x1400abcd, &77_u32.to_le_bytes())])?; Ok(BTreeMap::from([( @@ -688,10 +680,7 @@ mod tests { source.to_vec() }; let source = write_revisions(&source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; + let raw = index.resolve_active(index.root)?; let mut series = PropertyObject::from_object(&raw.objects[&series_id])?; series.remove(&[0x24003442])?; Ok(BTreeMap::from([( @@ -703,10 +692,7 @@ mod tests { for changed in [false, true] { for unrelated_edit in [false, true] { let result = write_revisions(&source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; + let raw = index.resolve_active(index.root)?; assert!(!raw.reachable()?.contains(©_id)); let mut copy = PropertyObject::from_object(&raw.objects[©_id])?; if changed { @@ -782,13 +768,9 @@ mod tests { for empty_series in [false, true] { let invalid_source = write_revisions(source, |index| { let target = if empty_series { index.root } else { sid }; - let raw = index.resolve( - target, - index.spaces[&target].labels[&(ExGuid::default(), 1)], - )?; + let raw = index.resolve_active(target)?; let id = if empty_series { - let space = &document.spaces[&document.root]; - let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let view = document.active(document.root).unwrap(); view.nodes[&view.roots[&1]].children[3] } else { raw.roots[&1] @@ -824,8 +806,7 @@ mod tests { let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let pages = document.pages().unwrap(); - let section = &document.spaces[&document.root]; - let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + let view = document.active(document.root).unwrap(); let series_id = view.nodes[&view.roots[&1]].children[3]; let field = view.nodes[&series_id].extra[0] .iter() @@ -836,10 +817,7 @@ mod tests { }; let copy_id = copies[0]; let source = write_revisions(source, |index| { - let raw = index.resolve( - index.root, - index.spaces[&index.root].labels[&(ExGuid::default(), 1)], - )?; + let raw = index.resolve_active(index.root)?; let mut objects = BTreeMap::new(); for id in [series_id, copy_id] { let mut object = PropertyObject::from_object(&raw.objects[&id])?; diff --git a/crates/onestore/src/store.rs b/crates/onestore/src/store.rs index d8cf334be8f076d613f4abd1f4768cd61957343c..bcbdefbe89dfaf4fbabeb3a570005ff68764c62f 100644 --- a/crates/onestore/src/store.rs +++ b/crates/onestore/src/store.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::ops::Range; -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub struct Error { /// Parser byte offset; zero also represents errors without a byte location. pub offset: usize, diff --git a/crates/onestore/tests/page_model.rs b/crates/onestore/tests/page_model.rs new file mode 100644 index 0000000000000000000000000000000000000000..2541b51c07785ad12012d5f874e5182cb66242a6 --- /dev/null +++ b/crates/onestore/tests/page_model.rs @@ -0,0 +1,112 @@ +use onestore::{ + RevisionIndex, Store, + document::Document, + page::{Page, PageObject, ParagraphContent}, +}; +use std::{collections::BTreeSet, fs, path::Path}; + +fn sections(directory: &Path, out: &mut Vec) { + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + if !matches!( + path.file_name().and_then(|n| n.to_str()), + Some("private" | "recovered") + ) { + sections(&path, out); + } + } else if path.extension().is_some_and(|e| e == "one") { + out.push(path); + } + } +} + +fn identities(page: &Page) -> Vec { + fn paragraphs(list: &[onestore::page::PageParagraph], ids: &mut Vec) { + for paragraph in list { + ids.push(paragraph.id); + match ¶graph.content { + ParagraphContent::Text(text) => ids.push(text.id), + ParagraphContent::Table(table) => { + ids.push(table.id); + for row in &table.rows { + ids.push(row.id); + for cell in &row.cells { + ids.push(cell.id); + paragraphs(&cell.paragraphs, ids); + ids.extend(cell.unsupported.iter().map(|u| u.id)); + } + } + } + ParagraphContent::Unsupported(unsupported) => ids.push(unsupported.id), + } + } + } + let mut ids = Vec::new(); + for object in &page.objects { + ids.push(object.id()); + match object { + PageObject::Outline(outline) => { + paragraphs(&outline.paragraphs, &mut ids); + ids.extend(outline.unsupported.iter().map(|u| u.id)); + } + PageObject::Title(title) => { + for outline in &title.outlines { + ids.push(outline.id); + paragraphs(&outline.paragraphs, &mut ids); + ids.extend(outline.unsupported.iter().map(|u| u.id)); + } + } + PageObject::Image(_) | PageObject::Unsupported(_) => {} + } + } + ids +} + +#[test] +fn every_corpus_page_builds_a_model_with_distinct_identities() { + let mut files = Vec::new(); + sections( + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")), + &mut files, + ); + files.sort(); + assert!(files.len() > 300, "{} sections", files.len()); + let mut pages = 0; + let mut failures = Vec::new(); + for path in &files { + let bytes = fs::read(path).unwrap(); + let Ok(store) = Store::parse(&bytes) else { + continue; + }; + let Ok(index) = RevisionIndex::parse(&store) else { + continue; + }; + let Ok(document) = Document::parse(&index) else { + continue; + }; + let Ok(listed) = document.pages() else { + continue; + }; + for (space, _) in listed { + pages += 1; + match Page::from_space(&document, space) { + Ok(page) => { + let ids = identities(&page); + let distinct: BTreeSet<_> = ids.iter().collect(); + if distinct.len() != ids.len() { + failures.push(format!("{}: repeated identity", path.display())); + } + } + Err(error) => failures.push(format!("{}: {}", path.display(), error.message)), + } + } + } + assert!(pages > 1000, "{pages} pages"); + assert!( + failures.is_empty(), + "{}\n{} failures", + failures.join("\n"), + failures.len() + ); +} -- 2.54.0