From c5acf4f0209f9e28a4496e04a421a11cb40b2984 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 10 Sep 2026 22:16:29 -0700 Subject: [PATCH] refactor: use the onestore page model in canvas Replace canvas::page, canvas::text and DateField with onestore::page; the inherent layout methods on Outline and Title become the Arrange trait and outline_layout, and parley affinities map through canvas::affinity. The branch-carried Layout.reserved_width and Format.list_spacing fields are ported to onestore in the rebased base commit. Validation: canvas 95 tests, snowbound 29 tests, workspace Clippy with warnings denied, fuzz canvas-editor target, and target/Snowbound.app built with its icon. Assisted-by: claude-fable-5.1 --- Cargo.lock | 1 - crates/canvas/Cargo.toml | 1 - crates/canvas/examples/render_page.rs | 3 +- crates/canvas/src/bin/layout_probe.rs | 3 +- crates/canvas/src/bin/page_probe.rs | 4 +- crates/canvas/src/date.rs | 20 +- crates/canvas/src/document.rs | 96 +- crates/canvas/src/editor.rs | 94 +- crates/canvas/src/editor/page.rs | 8 +- crates/canvas/src/editor/table.rs | 6 +- crates/canvas/src/gpu/mod.rs | 3 +- crates/canvas/src/gpu/page.rs | 41 +- crates/canvas/src/gpu/profile.rs | 4 +- crates/canvas/src/layout.rs | 2 +- crates/canvas/src/lib.rs | 10 +- crates/canvas/src/outline.rs | 110 +- crates/canvas/src/page.rs | 1337 ------------------------- crates/canvas/src/text.rs | 566 ----------- crates/snowbound/src/accessibility.rs | 13 +- crates/snowbound/src/main.rs | 14 +- fuzz/Cargo.lock | 1 - fuzz/fuzz_targets/canvas_editor.rs | 17 +- 22 files changed, 274 insertions(+), 2080 deletions(-) delete mode 100644 crates/canvas/src/page.rs delete mode 100644 crates/canvas/src/text.rs diff --git a/Cargo.lock b/Cargo.lock index eaf14dc49906654a7c04daa9ec40a2582f4b7966..83fa0aca68d4f841b8bb4b95a13d153854fd6cd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,7 +394,6 @@ name = "canvas" version = "0.1.0" dependencies = [ "bytemuck", - "getrandom 0.4.3", "image", "onestore", "parley", diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index b2de15cff31d0af90f66cf00821a858af46d20f8..98f08ba1a83c6c927efa737fa8ce84c5fce0219e 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -13,7 +13,6 @@ parley.workspace = true serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" skrifa = "0.44.0" -getrandom = "0.4.3" bytemuck = { version = "1.25", features = ["derive"], optional = true } swash = { version = "0.2.10", optional = true } diff --git a/crates/canvas/examples/render_page.rs b/crates/canvas/examples/render_page.rs index e21e395fef208433b7049783b166b6306a8d3722..27117cf744c8fbbb757630d2ef22586939f7ff92 100644 --- a/crates/canvas/examples/render_page.rs +++ b/crates/canvas/examples/render_page.rs @@ -1,5 +1,6 @@ use canvas::gpu::{Renderer, Viewport, page::PageScene}; -use canvas::{layout::TextEngine, page::Page}; +use canvas::layout::TextEngine; +use onestore::page::Page; use onestore::{RevisionIndex, Store, document::Document}; use std::{env, fs, sync::Arc, time::Duration}; diff --git a/crates/canvas/src/bin/layout_probe.rs b/crates/canvas/src/bin/layout_probe.rs index 4f3e5c131179513aca8c27d63d47c4c928a5def5..97bf3c90a439956f6249655078297d9e80dee32b 100644 --- a/crates/canvas/src/bin/layout_probe.rs +++ b/crates/canvas/src/bin/layout_probe.rs @@ -1,5 +1,6 @@ -use canvas::{layout::TextEngine, text::Paragraph}; +use canvas::layout::TextEngine; use onestore::document::Format; +use onestore::page::text::Paragraph; use serde::{Deserialize, Serialize}; use serde_json::json; use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; diff --git a/crates/canvas/src/bin/page_probe.rs b/crates/canvas/src/bin/page_probe.rs index 3b9004aae6eb887f92c357a92e92ecb7c3465bcc..4b5e0181cedc7615a3fa23464385d92b88790c70 100644 --- a/crates/canvas/src/bin/page_probe.rs +++ b/crates/canvas/src/bin/page_probe.rs @@ -1,9 +1,9 @@ use canvas::{ document::TextDocument, layout::TextEngine, - outline::OutlineLayout, - page::{Outline, Page, PageObject}, + outline::{Arrange, OutlineLayout}, }; +use onestore::page::{Outline, Page, PageObject}; use onestore::{RevisionIndex, Store, document::Document}; use serde_json::json; use std::{env, fs, io, sync::Arc, time::Instant}; diff --git a/crates/canvas/src/date.rs b/crates/canvas/src/date.rs index f36c607cd516049bdc51bb84fb8dcdf96a1f5288..fc7cfab6c42caa1d69af24a9b70837e03db109ba 100644 --- a/crates/canvas/src/date.rs +++ b/crates/canvas/src/date.rs @@ -2,18 +2,14 @@ use crate::{ document::TextDocument, editor::EditorError, layout::TextEngine, - outline::{OutlineLayout, TITLE_WIDTH}, - page::{Definition, Outline, PageParagraph}, - text::EditError, + outline::{OutlineLayout, TITLE_WIDTH, outline_layout}, }; use onestore::ExGuid; +use onestore::page::text::EditError; +use onestore::page::{Definition, Outline, PageParagraph}; use std::collections::BTreeMap; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DateField { - Date = 0, - Time = 1, -} +pub use onestore::page::DateField; #[derive(Clone)] pub struct PageDate { @@ -85,7 +81,7 @@ impl PageDate { { return Err(EditError::UnsupportedContent.into()); } - let layout = source.layout_with_width(engine, definitions, TITLE_WIDTH)?; + let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?; let date = Self { timestamp, source, @@ -104,10 +100,8 @@ impl PageDate { #[cfg(test)] mod tests { use super::*; - use crate::{ - editor::{CanvasEditor, TextOutline}, - text::Paragraph, - }; + use crate::editor::{CanvasEditor, TextOutline}; + use onestore::page::text::Paragraph; #[test] fn date_and_text_edits_share_history_without_changing_identities_or_selection() { diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index 19c3522f32f5b6d2e916a0897bb8e6ad41636118..e5bb2edb513e7607dabd6e5c527cfd4a81550c28 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -1,7 +1,5 @@ -use crate::{ - page::{PageParagraph, ParagraphContent, TextObject}, - text::{EditError, Paragraph, new_id}, -}; +use onestore::page::text::{EditError, Paragraph, new_id}; +use onestore::page::{PageParagraph, ParagraphContent, TextObject}; use onestore::{ExGuid, document::Format}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -36,7 +34,7 @@ pub(crate) fn node(text: Paragraph, format: Format) -> Result TextPosition { TextPosition { paragraph, offset } @@ -781,7 +779,7 @@ mod tests { #[test] #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] fn imported_nodes_preserve_identity_through_edit_and_undo() { - use crate::page::{Page, PageObject}; + use onestore::page::{Page, PageObject}; use onestore::{RevisionIndex, Store, document::Document}; let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); let page = { @@ -939,7 +937,7 @@ mod tests { assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure)); let mut unsupported = original.nodes().to_vec(); unsupported[0].content = - crate::page::ParagraphContent::Unsupported(crate::page::Unsupported { + onestore::page::ParagraphContent::Unsupported(onestore::page::Unsupported { id: new_id().unwrap(), jcid: 0x60012, layout: Default::default(), @@ -1270,4 +1268,86 @@ mod tests { } assert!(TextDocument::new(Vec::new()).is_err()); } + + #[test] + #[ignore = "requires CANVAS_TEST_SECTION pointing to the native Tab table capture"] + fn native_table_import() { + use onestore::{ + RevisionIndex, Store, + document::Document, + page::{Page, PageObject, ParagraphContent}, + }; + let page = { + let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + for (space, page) in document.pages().unwrap() { + let space = &document.spaces[&space]; + let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; + Page::from_revision(revision, page).unwrap(); + } + Page::from_document(&document, "rows").unwrap() + }; + let table = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline.paragraphs.iter().find_map(|p| match &p.content { + ParagraphContent::Table(table) => Some(table), + _ => None, + }) + } + _ => None, + }) + .unwrap(); + assert_eq!(table.rows.len(), 3); + assert_eq!(table.columns.len(), 2); + assert!(table.columns.iter().all(|c| !c.locked)); + let cells: Vec<_> = table.rows.iter().flat_map(|row| &row.cells).collect(); + let text: Vec<_> = cells + .iter() + .map(|cell| cell.paragraphs[0].text().unwrap().text.text()) + .collect(); + assert_eq!(text, ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", ""]); + assert!(cells.iter().all(|c| c.indents == [18.0, 0.0, 27.0, 27.0])); + let outline = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) + if outline + .paragraphs + .iter() + .any(|p| matches!(p.content, ParagraphContent::Table(_))) => + { + Some(outline) + } + _ => None, + }) + .unwrap(); + let mut document = TextDocument::from_nodes(outline.paragraphs.clone()).unwrap(); + let original = document.clone(); + for (index, cell) in cells.iter().enumerate() { + let position = TextPosition { + paragraph: index, + offset: 0, + }; + let edit = document + .replace( + position..position, + vec![ + Paragraph::new("🧊".into(), Format::default()), + Paragraph::new("text".into(), Format::default()), + ], + ) + .unwrap(); + assert_eq!(edit.container, Some(cell.id)); + let undo = document.apply(edit).unwrap(); + assert_eq!(document.paragraphs().nth(index).unwrap().text(), "🧊"); + document.apply(undo).unwrap(); + assert_eq!(document, original); + } + } } diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 961241bbac764878132ee9ae4dd9c66f69184071..1e252ff37f969aaf6d2c07fd77acd2173fda6f8e 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -2,11 +2,14 @@ use crate::{ date::PageDate, document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves}, layout::{LayoutError, TextEngine}, - outline::{OutlineLayout, ParagraphLayout, arrange, indentation, visible_paragraphs}, - page::{Definition, Outline, PageParagraph}, - text::{EditError, Paragraph}, + outline::{ + Arrange, OutlineLayout, ParagraphLayout, arrange, indentation, outline_layout, + visible_paragraphs, + }, }; use onestore::ExGuid; +use onestore::page::text::{EditError, Paragraph}; +use onestore::page::{Definition, Outline, PageParagraph}; use parley::{ Affinity, BoundingBox, editing::{Cursor, Selection as ParagraphSelection}, @@ -180,7 +183,7 @@ impl TextOutline { }, )?; Ok(Self { - id: crate::text::new_id()?, + id: onestore::page::text::new_id()?, title: false, min_width: None, layout: onestore::document::Layout { @@ -227,7 +230,8 @@ impl TextOutline { { return Err(LayoutError::InvalidSpacing.into()); } - outline.layout_with_width( + outline_layout( + outline, engine, definitions, outline @@ -399,14 +403,14 @@ impl TextOutline { continue; }; match &node.content { - crate::page::ParagraphContent::Text(_) => { + onestore::page::ParagraphContent::Text(_) => { result.push( *paragraphs .get(&node.id) .ok_or(EditError::InvalidStructure)?, ); } - crate::page::ParagraphContent::Table(table) => { + onestore::page::ParagraphContent::Table(table) => { let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?; let cells = layout .cells @@ -441,7 +445,7 @@ impl TextOutline { pending.push(visible_paragraphs(cell.paragraphs.iter())); } } - crate::page::ParagraphContent::Unsupported(_) => { + onestore::page::ParagraphContent::Unsupported(_) => { return Err(EditError::UnsupportedContent); } } @@ -475,14 +479,14 @@ impl TextOutline { let mut target = None; for node in visible_paragraphs(nodes.iter()) { let top = match &node.content { - crate::page::ParagraphContent::Text(_) => { + onestore::page::ParagraphContent::Text(_) => { paragraphs .get(&node.id) .ok_or(EditError::InvalidStructure)? .1 .origin[1] } - crate::page::ParagraphContent::Table(table) => { + onestore::page::ParagraphContent::Table(table) => { tables .get(&table.id) .and_then(|layout| layout.cells.first()) @@ -490,7 +494,7 @@ impl TextOutline { .rect[1] - 1.86 } - crate::page::ParagraphContent::Unsupported(_) => { + onestore::page::ParagraphContent::Unsupported(_) => { return Err(EditError::UnsupportedContent); } }; @@ -502,7 +506,7 @@ impl TextOutline { } } let target = target.ok_or(EditError::InvalidStructure)?; - let crate::page::ParagraphContent::Table(table) = &target.content else { + let onestore::page::ParagraphContent::Table(table) = &target.content else { return paragraphs .get(&target.id) .map(|(index, _)| *index) @@ -648,7 +652,7 @@ impl CanvasEditor { } pub fn from_page( - mut page: crate::page::Page, + mut page: onestore::page::Page, engine: &mut TextEngine, ) -> Result { let (objects, mut outlines, date) = page::build(&mut page, engine, true)?; @@ -818,7 +822,7 @@ impl CanvasEditor { )) } page::Content::ReadOnly(object) - if !matches!(object.source, crate::page::PageObject::Title(_)) => + if !matches!(object.source, onestore::page::PageObject::Title(_)) => { Some((object.source.id(), object.source.layout(), object.rect())) } @@ -1329,7 +1333,7 @@ impl CanvasEditor { paragraph: self.active_outline().source_index(index), offset: paragraph .projection - .source_offset(visible, cursor.affinity())?, + .source_offset(visible, crate::affinity(cursor.affinity()))?, }; selection.affinities[slot] = cursor.affinity(); } @@ -1579,7 +1583,7 @@ impl CanvasEditor { } else if outline.origin()[0] >= 180.0 && trailing >= 2 { let node = &outline.document.nodes()[last.saturating_sub(trailing)]; let bottom = match &node.content { - crate::page::ParagraphContent::Text(_) => { + onestore::page::ParagraphContent::Text(_) => { let paragraph = outline .shaped .paragraphs @@ -1588,7 +1592,7 @@ impl CanvasEditor { .ok_or(EditError::InvalidStructure)?; f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height()) } - crate::page::ParagraphContent::Table(table) => { + onestore::page::ParagraphContent::Table(table) => { let cell = outline .shaped .tables @@ -1598,7 +1602,7 @@ impl CanvasEditor { .ok_or(EditError::InvalidStructure)?; f64::from(cell.rect[3]) + 1.68 } - crate::page::ParagraphContent::Unsupported(_) => { + onestore::page::ParagraphContent::Unsupported(_) => { return Err(EditError::UnsupportedContent.into()); } } + f64::from(outline.origin()[1]); @@ -1773,7 +1777,7 @@ impl CanvasEditor { paragraph: self.active_outline().source_index(index), offset: paragraph .projection - .source_offset(visible, next.affinity())?, + .source_offset(visible, crate::affinity(next.affinity()))?, }; if !extend { self.active_outline_mut().selection.positions[0] = position; @@ -1984,12 +1988,14 @@ impl CanvasEditor { { let visible = paragraph.projection.text(); let bytes = cluster.text_range(); - range.start.offset = paragraph - .projection - .source_offset(visible.utf16_offset(bytes.start)?, Affinity::Downstream)?; - range.end.offset = paragraph - .projection - .source_offset(visible.utf16_offset(bytes.end)?, Affinity::Upstream)?; + range.start.offset = paragraph.projection.source_offset( + visible.utf16_offset(bytes.start)?, + onestore::page::text::Affinity::Downstream, + )?; + range.end.offset = paragraph.projection.source_offset( + visible.utf16_offset(bytes.end)?, + onestore::page::text::Affinity::Upstream, + )?; } else { let document = &self.active_outline().document; let Some(neighbor) = (if backward { @@ -2690,7 +2696,7 @@ impl CanvasEditor { .nodes() .iter() .chain(&edit.replacement) - .any(|node| matches!(node.content, crate::page::ParagraphContent::Table(_))) + .any(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_))) { let sources = outline .document @@ -2891,10 +2897,8 @@ mod tests { use onestore::document::Format; fn table_editor(engine: &mut TextEngine) -> CanvasEditor { - use crate::{ - page::{ParagraphContent, Table, TableCell, TableColumn, TableRow}, - text::new_id, - }; + use onestore::page::text::new_id; + use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow}; let mut editor = CanvasEditor::new( engine, TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(), @@ -2975,7 +2979,8 @@ mod tests { fn structured_construction_and_resize_preserve_cells_and_enclose_locked_columns() { let mut engine = TextEngine::default(); let mut source = table_editor(&mut engine).active_outline().snapshot(); - let crate::page::ParagraphContent::Table(table) = &mut source.paragraphs[0].content else { + let onestore::page::ParagraphContent::Table(table) = &mut source.paragraphs[0].content + else { panic!() }; table.rows[0].cells[0].paragraphs[0] @@ -3064,7 +3069,7 @@ mod tests { #[test] fn nested_table_extents_remain_reachable_beyond_the_parent_columns() { - use crate::page::ParagraphContent; + use onestore::page::ParagraphContent; let mut engine = TextEngine::default(); let mut source = table_editor(&mut engine).active_outline().snapshot(); let mut nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone(); @@ -3215,7 +3220,8 @@ mod tests { #[test] fn nested_table_mouse_targets_resolve_the_source_cell() { - use crate::{page::ParagraphContent, text::new_id}; + use onestore::page::ParagraphContent; + use onestore::page::text::new_id; let mut engine = TextEngine::default(); let nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone(); let mut editor = table_editor(&mut engine); @@ -3403,7 +3409,7 @@ mod tests { #[test] fn overflowing_cell_caret_moves_to_the_next_rows_visual_column() { - use crate::page::ParagraphContent; + use onestore::page::ParagraphContent; let mut engine = TextEngine::default(); let mut source = table_editor(&mut engine).active_outline().snapshot(); let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else { @@ -3804,7 +3810,7 @@ mod tests { #[test] fn column_width_edits_reflow_and_restore_structured_drafts() { - use crate::page::ParagraphContent; + use onestore::page::ParagraphContent; let mut engine = TextEngine::default(); for draft in [false, true] { let mut editor = table_editor(&mut engine); @@ -4193,7 +4199,7 @@ mod tests { #[test] fn cross_outline_validation_includes_table_row_and_cell_ids() { - use crate::page::ParagraphContent; + use onestore::page::ParagraphContent; let mut engine = TextEngine::default(); let first = table_editor(&mut engine).active_outline().clone(); let second = table_editor(&mut engine).active_outline().clone(); @@ -4225,7 +4231,7 @@ mod tests { #[test] fn title_flow_moves_page_objects_atomically_and_cancels_composition() { - use crate::page::{Image, Page, PageObject, Title}; + use onestore::page::{Image, Page, PageObject, Title}; let mut engine = TextEngine::default(); let mut title = TextOutline::new( &mut engine, @@ -4254,7 +4260,7 @@ mod tests { (120.0, 80.0, true), ] .map(|(x, y, background)| Image { - id: crate::text::new_id().unwrap(), + id: onestore::page::text::new_id().unwrap(), layout: onestore::document::Layout { x: Some(x), y: Some(y), @@ -4272,7 +4278,7 @@ mod tests { .map(|image| image.bytes.as_ref().unwrap().clone()); let mut objects = vec![ PageObject::Title(Title { - id: crate::text::new_id().unwrap(), + id: onestore::page::text::new_id().unwrap(), date: None, layout: Default::default(), outlines: vec![title], @@ -5597,7 +5603,7 @@ mod tests { #[test] fn native_outline_omits_outer_paragraph_spacing_through_split_and_undo() { - use crate::page::{Page, PageObject}; + use onestore::page::{Page, PageObject}; use onestore::{RevisionIndex, Store, document::Document}; let store = Store::parse(include_bytes!( "../../../resources/canvas/baseline-anchors.one" @@ -5740,7 +5746,7 @@ mod tests { let outline = Outline { title: false, min_width: None, - id: crate::text::new_id().unwrap(), + id: onestore::page::text::new_id().unwrap(), layout: onestore::document::Layout { max_width: Some(220.0), reserved_width: Some(180.0), @@ -5812,7 +5818,7 @@ mod tests { nodes[2].collapsed = true; nodes[3].parent = Some(nodes[2].id); nodes[3].level = 3; - let marker = crate::text::new_id().unwrap(); + let marker = onestore::page::text::new_id().unwrap(); nodes[1].lists.push(marker); let definitions = BTreeMap::from([( marker, @@ -5832,7 +5838,7 @@ mod tests { let outline = Outline { title: false, min_width: None, - id: crate::text::new_id().unwrap(), + id: onestore::page::text::new_id().unwrap(), layout: onestore::document::Layout { x: Some(30.0), y: Some(45.0), @@ -5963,7 +5969,7 @@ mod tests { #[test] #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] fn imported_editor_reflows_and_restores_native_outline_geometry() { - use crate::page::{Page, PageObject}; + use onestore::page::{Page, PageObject}; use onestore::{RevisionIndex, Store, document::Document}; let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); let page = { diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index bbdf468641bc3b72fd4206eb233fd3ee48c14ae5..ec274270d42c2ef46c3d6dadc8e82c90569f9c8b 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -2,10 +2,10 @@ use super::{EditorError, TextOutline}; use crate::{ date::PageDate, layout::{LayoutError, TextEngine, TextLayout}, - outline::OutlineLayout, - page::{Image, Outline, Page, PageObject}, - text::Paragraph, + outline::{Arrange, OutlineLayout}, }; +use onestore::page::text::Paragraph; +use onestore::page::{Image, Outline, Page, PageObject}; pub(crate) enum Content { Date { @@ -246,7 +246,7 @@ pub(crate) fn build( continue; } Err(EditorError::Edit( - crate::text::EditError::UnsupportedContent, + onestore::page::text::EditError::UnsupportedContent, )) => {} Err(error) => return Err(error), } diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index 856d3a470757517ec0d27b5daf228c6afb660add..e2cd08e55775679c1e0df08aac2e33be8de0b6b1 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -1,6 +1,6 @@ use super::*; -use crate::page::{ParagraphContent, TableCell, TableColumn, TableRow}; -use crate::text::new_id; +use onestore::page::text::new_id; +use onestore::page::{ParagraphContent, TableCell, TableColumn, TableRow}; struct CellLocation<'a> { container: Option, @@ -125,7 +125,7 @@ impl CanvasEditor { lists: Vec::new(), tags: Vec::new(), collapsed: false, - content: ParagraphContent::Table(crate::page::Table { + content: ParagraphContent::Table(onestore::page::Table { id: new_id()?, columns: vec![ TableColumn { diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 827e63d7db7ed3151ac7f28d7ed6c9b3dec572c1..ca06713db9022348150b8d027b9f052a991c582f 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -1019,8 +1019,9 @@ pub fn colorref(color: u32) -> [f32; 4] { #[cfg(test)] mod tests { use super::*; - use crate::{layout::TextEngine, text::Paragraph}; + use crate::layout::TextEngine; use onestore::document::Format as TextFormat; + use onestore::page::text::Paragraph; use std::time::Duration; #[test] diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 53057607da4494f1108e3dc3e75dd8b84b41eb11..839fe1f13d9f4c38ef3337d50c475d1fd14dc439 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -5,8 +5,8 @@ use crate::{ date::DateField, editor::{CanvasEditor, EditorError}, layout::{LayoutError, TextEngine}, - page::Page, }; +use onestore::page::Page; use std::fmt; /// Retained drawing data in the source page's coordinate system. @@ -519,17 +519,16 @@ impl crate::outline::OutlineLayout { #[cfg(test)] mod tests { use super::*; - use crate::{ - editor::TextOutline, - page::{Image, PageObject}, - text::Paragraph, - }; + use crate::editor::TextOutline; + use onestore::page::text::Paragraph; + use onestore::page::{Image, PageObject}; use onestore::{ExGuid, document::Layout}; use std::{collections::BTreeMap, sync::Arc}; #[test] fn title_editing_keeps_identity_and_date_geometry_through_composition_and_undo() { - use crate::{document::TextDocument, page::Title}; + use crate::document::TextDocument; + use onestore::page::Title; let mut engine = TextEngine::default(); let mut fields = ["Header", "September 8, 2026"].map(|text| { let document = @@ -740,7 +739,7 @@ mod tests { vec![outline], BTreeMap::from([( style, - crate::page::Definition { + onestore::page::Definition { kind: onestore::document::Kind::Style { name: Some("p".into()), }, @@ -797,7 +796,8 @@ mod tests { #[test] #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"] fn native_title_flow() { - use crate::{document::TextDocument, page::Image}; + use crate::document::TextDocument; + use onestore::page::Image; let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); let store = onestore::Store::parse(&bytes).unwrap(); let index = onestore::RevisionIndex::parse(&store).unwrap(); @@ -906,7 +906,7 @@ mod tests { (120.0, 60.0, false), ] { page.objects.push(PageObject::Image(Image { - id: crate::text::new_id().unwrap(), + id: onestore::page::text::new_id().unwrap(), bytes: Some(image_bytes.clone()), layout: Layout { x: Some(x), @@ -961,7 +961,8 @@ mod tests { #[test] fn title_exit_places_an_uncommitted_body_caret_below_the_date() { - use crate::{document::TextDocument, page::Title}; + use crate::document::TextDocument; + use onestore::page::Title; let mut engine = TextEngine::default(); let fields = [("Header", 20.751953), ("Date\u{000b}Time", 12.207031)].map(|(text, line_spacing)| { @@ -1040,7 +1041,7 @@ mod tests { let objects = [0.0, 20.0] .into_iter() .map(|position| { - PageObject::Unsupported(crate::page::Unsupported { + PageObject::Unsupported(onestore::page::Unsupported { id: ExGuid { guid: [position as u8; 16], n: 1, @@ -1080,11 +1081,9 @@ mod tests { #[test] fn unsupported_tags_and_titles_leave_supported_outlines_editable() { - use crate::{ - document::TextDocument, - page::{Definition, Outline, Title}, - }; + use crate::document::TextDocument; use onestore::document::{Kind, Tag}; + use onestore::page::{Definition, Outline, Title}; for title in [false, true] { let definition = ExGuid { guid: [9; 16], @@ -1190,10 +1189,8 @@ mod tests { #[test] fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() { - use crate::{ - document::TextDocument, - page::{Outline, Unsupported}, - }; + use crate::document::TextDocument; + use onestore::page::{Outline, Unsupported}; let document = TextDocument::new(vec![Paragraph::new( "preserved text".into(), Default::default(), @@ -1347,7 +1344,9 @@ mod tests { #[test] fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() { - use crate::{document::TextDocument, page::Outline, text::Paragraph}; + use crate::document::TextDocument; + use onestore::page::Outline; + use onestore::page::text::Paragraph; let outline = |x, text: &str| { let document = TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap(); diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs index 6c1b91e56957eeaba563875511294d3175d93c33..e94fd380920b8b2a095caf7a4858b3ac2de8fe0e 100644 --- a/crates/canvas/src/gpu/profile.rs +++ b/crates/canvas/src/gpu/profile.rs @@ -1,5 +1,7 @@ use super::*; -use crate::{layout::TextEngine, page::Page, text::Paragraph}; +use crate::layout::TextEngine; +use onestore::page::Page; +use onestore::page::text::Paragraph; use std::{ sync::Arc, time::{Duration, Instant}, diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 94219cc111f07f832d5875785d814acf131bcf09..0eff9676a98272be2649d1705ef0ea5b6bddc46a 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -1,4 +1,4 @@ -use crate::text::Paragraph; +use onestore::page::text::Paragraph; use parley::{ Affinity, Alignment, AlignmentOptions, BoundingBox, FontContext, FontFamily, FontStyle, FontWeight, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty, diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index 5e48695d65c481f198f8a98e647b50cf28ab0d03..3168c704550f0e073eade482b14df34401649127 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -2,9 +2,15 @@ pub mod document; pub mod editor; pub mod layout; pub mod outline; -pub mod page; -pub mod text; pub mod date; #[cfg(feature = "gpu")] pub mod gpu; + +/// Parley's caret affinity mapped onto the page model's hidden-field affinity. +pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { + match affinity { + parley::Affinity::Upstream => onestore::page::text::Affinity::Upstream, + parley::Affinity::Downstream => onestore::page::text::Affinity::Downstream, + } +} diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 64b9d6bf3ac8208852e0c6286827142ccc168f25..55eabfb43753088f994672cc08a8c27597b69b7f 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -1,9 +1,9 @@ use crate::{ document::{DocumentEdit, edited_nodes}, layout::{LayoutError, TextEngine, TextLayout}, - page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title}, - text::{Paragraph, TextProjection}, }; +use onestore::page::text::{Paragraph, TextProjection}; +use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title}; use onestore::{ ExGuid, document::{Format, Kind}, @@ -498,8 +498,20 @@ impl OutlineLayout { } } -impl Outline { - pub fn layout( +/// Shapes a page object into positioned paragraph layouts. +pub trait Arrange { + type Output; + fn layout( + &self, + engine: &mut TextEngine, + definitions: &BTreeMap, + ) -> Result; +} + +impl Arrange for Outline { + type Output = OutlineLayout; + + fn layout( &self, engine: &mut TextEngine, definitions: &BTreeMap, @@ -509,51 +521,53 @@ impl Outline { .reserved_width .or(self.layout.max_width) .ok_or(LayoutError::InvalidWidth)?; - self.layout_with_width(engine, definitions, width) + outline_layout(self, engine, definitions, width) } +} - pub(crate) fn layout_with_width( - &self, - engine: &mut TextEngine, - definitions: &BTreeMap, - width: f32, - ) -> Result { - if self.indents.len() < 2 || self.indents.iter().any(|v| !v.is_finite() || *v < 0.0) { - return Err(LayoutError::InvalidIndentation); - } - if !self.unsupported.is_empty() { - return Err(LayoutError::UnsupportedContent); - } - if self - .paragraphs - .iter() - .any(|node| matches!(node.content, ParagraphContent::Table(_))) - { - return OutlineLayout::flow( - self.paragraphs.iter(), - &self.indents, - width, - self.layout.width_set_by_user == Some(true), - 0, - None, - &mut |node, width, indents| { - ParagraphLayout::shape(engine, node, width, indents, definitions) - }, - ); - } - let paragraphs = visible_paragraphs(self.paragraphs.iter()) - .map(|p| ParagraphLayout::shape(engine, p, width, &self.indents, definitions)) - .collect::>()?; - OutlineLayout::new( - paragraphs, +pub(crate) fn outline_layout( + outline: &Outline, + engine: &mut TextEngine, + definitions: &BTreeMap, + width: f32, +) -> Result { + if outline.indents.len() < 2 || outline.indents.iter().any(|v| !v.is_finite() || *v < 0.0) { + return Err(LayoutError::InvalidIndentation); + } + if !outline.unsupported.is_empty() { + return Err(LayoutError::UnsupportedContent); + } + if outline + .paragraphs + .iter() + .any(|node| matches!(node.content, ParagraphContent::Table(_))) + { + return OutlineLayout::flow( + outline.paragraphs.iter(), + &outline.indents, width, - self.layout.width_set_by_user == Some(true), - ) + outline.layout.width_set_by_user == Some(true), + 0, + None, + &mut |node, width, indents| { + ParagraphLayout::shape(engine, node, width, indents, definitions) + }, + ); } + let paragraphs = visible_paragraphs(outline.paragraphs.iter()) + .map(|p| ParagraphLayout::shape(engine, p, width, &outline.indents, definitions)) + .collect::>()?; + OutlineLayout::new( + paragraphs, + width, + outline.layout.width_set_by_user == Some(true), + ) } -impl Title { - pub fn layout( +impl Arrange for Title { + type Output = Vec<([f32; 2], OutlineLayout)>; + + fn layout( &self, engine: &mut TextEngine, definitions: &BTreeMap, @@ -573,7 +587,7 @@ impl Title { .reserved_width .or(outline.layout.max_width) .unwrap_or(TITLE_WIDTH); - let layout = outline.layout_with_width(engine, definitions, width)?; + let layout = outline_layout(outline, engine, definitions, width)?; let origin = [ outline.layout.x.unwrap_or(0.0), bottom + outline.layout.y.unwrap_or(0.0), @@ -595,8 +609,8 @@ impl Title { #[cfg(test)] mod tests { use super::*; - use crate::page::{PageParagraph, TextObject}; use onestore::document::Layout; + use onestore::page::{PageParagraph, TextObject}; fn paragraph(n: u32, text: &str, level: u32, parent: Option) -> PageParagraph { PageParagraph { @@ -610,7 +624,7 @@ mod tests { }), level, format: Format::default(), - content: crate::page::ParagraphContent::Text(TextObject { + content: onestore::page::ParagraphContent::Text(TextObject { date_field: None, id: ExGuid { n: n + 100, @@ -627,7 +641,7 @@ mod tests { } fn table(rows: &[&[&str]], widths: &[f32], mut n: u32) -> PageParagraph { - use crate::page::{TableCell, TableColumn, TableRow}; + use onestore::page::{TableCell, TableColumn, TableRow}; let mut id = || { n += 1; ExGuid { @@ -816,7 +830,7 @@ mod tests { #[test] #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"] fn native_table_layout() { - use crate::page::{Page, PageObject}; + use onestore::page::{Page, PageObject}; use onestore::{RevisionIndex, Store, document::Document}; let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); let store = Store::parse(&bytes).unwrap(); diff --git a/crates/canvas/src/page.rs b/crates/canvas/src/page.rs deleted file mode 100644 index 29f333fa77ea6159337a51085f42f20b9de1bcae..0000000000000000000000000000000000000000 --- a/crates/canvas/src/page.rs +++ /dev/null @@ -1,1337 +0,0 @@ -use crate::date::DateField; -use crate::text::Paragraph; -use onestore::{ - ExGuid, - document::{Document, Format, Kind, Layout, Revision, Tag}, -}; -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, -}; - -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(crate) 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(crate) 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(crate) 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 space = &document.spaces[&space]; - let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; - if page_title(revision, id) == Some(title) { - if selected.is_some() { - return Err(onestore::Error { - offset: 0, - message: "More than one active page has the requested title", - }); - } - selected = Some((revision, id)); - } - } - let (revision, id) = selected.ok_or(onestore::Error { - offset: 0, - message: "No active page has the requested title", - })?; - Self::from_revision(revision, id) - } - - pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result { - let invalid = |message| onestore::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 onestore::document::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, onestore::document::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 onestore::document::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, onestore::document::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: &onestore::document::Element<'_>, - inherited: &Format, - definitions: &mut BTreeMap, - seen: &mut BTreeSet, - depth: usize, -) -> Result<(Vec, Vec), onestore::Error> { - let invalid = |message| onestore::Error { offset: 0, message }; - let default_format = Format::default(); - let style = |id: Option| -> Result<&Format, onestore::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, onestore::document::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| onestore::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 onestore::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] - #[ignore = "requires CANVAS_TEST_SECTION pointing to the native Tab table capture"] - fn native_table_import() { - use onestore::{RevisionIndex, Store}; - let page = { - let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); - let store = Store::parse(&bytes).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - for (space, page) in document.pages().unwrap() { - let space = &document.spaces[&space]; - let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; - Page::from_revision(revision, page).unwrap(); - } - Page::from_document(&document, "rows").unwrap() - }; - let table = page - .objects - .iter() - .find_map(|object| match object { - PageObject::Outline(outline) => { - outline.paragraphs.iter().find_map(|p| match &p.content { - ParagraphContent::Table(table) => Some(table), - _ => None, - }) - } - _ => None, - }) - .unwrap(); - assert_eq!(table.rows.len(), 3); - assert_eq!(table.columns.len(), 2); - assert!(table.columns.iter().all(|c| !c.locked)); - let cells: Vec<_> = table.rows.iter().flat_map(|row| &row.cells).collect(); - let text: Vec<_> = cells - .iter() - .map(|cell| cell.paragraphs[0].text().unwrap().text.text()) - .collect(); - assert_eq!(text, ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", ""]); - assert!(cells.iter().all(|c| c.indents == [18.0, 0.0, 27.0, 27.0])); - use crate::document::{TextDocument, TextPosition}; - let outline = page - .objects - .iter() - .find_map(|object| match object { - PageObject::Outline(outline) - if outline - .paragraphs - .iter() - .any(|p| matches!(p.content, ParagraphContent::Table(_))) => - { - Some(outline) - } - _ => None, - }) - .unwrap(); - let mut document = TextDocument::from_nodes(outline.paragraphs.clone()).unwrap(); - let original = document.clone(); - for (index, cell) in cells.iter().enumerate() { - let position = TextPosition { - paragraph: index, - offset: 0, - }; - let edit = document - .replace( - position..position, - vec![ - Paragraph::new("🧊".into(), Format::default()), - Paragraph::new("text".into(), Format::default()), - ], - ) - .unwrap(); - assert_eq!(edit.container, Some(cell.id)); - let undo = document.apply(edit).unwrap(); - assert_eq!(document.paragraphs().nth(index).unwrap().text(), "🧊"); - document.apply(undo).unwrap(); - assert_eq!(document, original); - } - } - - #[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 onestore::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 onestore::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: onestore::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/canvas/src/text.rs b/crates/canvas/src/text.rs deleted file mode 100644 index b0e5abe7b5665336130a231927bdab7f9423a6f8..0000000000000000000000000000000000000000 --- a/crates/canvas/src/text.rs +++ /dev/null @@ -1,566 +0,0 @@ -use onestore::document::Format; -use parley::Affinity; -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, -} - -#[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))) - } - - /// Affinity selects the side of a hidden field at a visible boundary. - 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, - Identity(getrandom::Error), -} - -impl fmt::Display for EditError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::Identity(error) => return error.fmt(f), - 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 {} - -pub(crate) fn new_id() -> Result { - let mut guid = [0; 16]; - getrandom::fill(&mut guid).map_err(EditError::Identity)?; - guid[7] = (guid[7] & 0x0f) | 0x40; - guid[8] = (guid[8] & 0x3f) | 0x80; - Ok(onestore::ExGuid { guid, 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/snowbound/src/accessibility.rs b/crates/snowbound/src/accessibility.rs index c520a7a9d0a0e519ad572388e19919dc91f077b7..289955ef6e98fdf870c04eaaae3b299ce579c7e1 100644 --- a/crates/snowbound/src/accessibility.rs +++ b/crates/snowbound/src/accessibility.rs @@ -7,8 +7,8 @@ use canvas::{ date::DateField, document::TextPosition, editor::{CanvasEditor, Selection, TextOutline}, - text::EditError, }; +use onestore::page::text::EditError; use parley::Affinity; use std::{collections::HashMap, ops::Range}; @@ -519,7 +519,7 @@ impl Accessibility { ); position.offset = projection.source_offset( projection.text().utf16_offset(cursor.index())?, - Affinity::Downstream, + onestore::page::text::Affinity::Downstream, )?; let caret = layout.caret(cursor, 0.0); let mut positions = self @@ -584,12 +584,13 @@ impl Accessibility { #[cfg(test)] mod tests { use super::*; - use canvas::{document::TextDocument, layout::TextEngine, text::Paragraph}; + use canvas::{document::TextDocument, layout::TextEngine}; use onestore::document::Format; + use onestore::page::text::Paragraph; #[test] fn title_flow_updates_read_only_hit_and_accessibility_bounds() { - use canvas::page::{Page, PageObject, Title, Unsupported}; + use onestore::page::{Page, PageObject, Title, Unsupported}; let mut engine = TextEngine::default(); let mut title = TextOutline::new( &mut engine, @@ -697,7 +698,7 @@ mod tests { #[test] fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() { use canvas::gpu::page::PageScene; - use canvas::page::{Page, PageObject, Unsupported}; + use onestore::page::{Page, PageObject, Unsupported}; let page = Page { created: None, title: String::new(), @@ -780,7 +781,7 @@ mod tests { #[test] fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() { - use canvas::page::{Definition, Outline}; + use onestore::page::{Definition, Outline}; use onestore::{ ExGuid, document::{Kind, Layout, Tag}, diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index f76bb29d024367cb9082cbbbc7e75501f985b042..19ff3a3e07e4ce2eedc0a3800859b2b5ac774d07 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -12,10 +12,10 @@ use canvas::{ CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, }, layout::TextEngine, - page::Page, - text::Paragraph, }; use onestore::document::Format; +use onestore::page::Page; +use onestore::page::text::Paragraph; use std::{ error::Error, path::PathBuf, @@ -1920,7 +1920,7 @@ mod tests { created: Some(1), margin_origin: [36.0, 14.4], definitions: Default::default(), - objects: vec![canvas::page::PageObject::Title(canvas::page::Title { + objects: vec![onestore::page::PageObject::Title(onestore::page::Title { id: onestore::ExGuid::default(), date: Some(fields[1].id), layout: Default::default(), @@ -2018,12 +2018,12 @@ mod tests { None ); } - let page = canvas::page::Page { + let page = onestore::page::Page { title: String::new(), created: None, margin_origin: [0.0; 2], definitions: Default::default(), - objects: vec![canvas::page::PageObject::Outline(source)], + objects: vec![onestore::page::PageObject::Outline(source)], }; let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); let offset = [50.0, 300.0]; @@ -2713,7 +2713,7 @@ mod tests { #[test] fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { - use canvas::page::{Outline, PageObject, Unsupported}; + use onestore::page::{Outline, PageObject, Unsupported}; for readonly_on_top in [false, true] { let mut engine = TextEngine::default(); let document = TextDocument::new(vec![Paragraph::new( @@ -2924,7 +2924,7 @@ mod tests { #[test] fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { - use canvas::page::{PageObject, Unsupported}; + use onestore::page::{PageObject, Unsupported}; let mut engine = TextEngine::default(); let mut editor = CanvasEditor::new( &mut engine, diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index bcfecae555fff835c0a7b8b762ac5346638d1fbe..71d81cf9569121fd47613819f1e67dc553d67584 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -81,7 +81,6 @@ dependencies = [ name = "canvas" version = "0.1.0" dependencies = [ - "getrandom", "onestore", "parley", "serde", diff --git a/fuzz/fuzz_targets/canvas_editor.rs b/fuzz/fuzz_targets/canvas_editor.rs index 3c83c28a2b578e68f6222b5791deaad8afba3981..33022b82198deace7050f322de5d457a5799ea37 100644 --- a/fuzz/fuzz_targets/canvas_editor.rs +++ b/fuzz/fuzz_targets/canvas_editor.rs @@ -1,11 +1,6 @@ #![no_main] -use canvas::{ - date::PageDate, - document::{TextDocument, TextPosition}, - editor::{CanvasEditor, Movement, SelectionUnit, TextOutline}, - layout::TextEngine, - text::Paragraph, -}; +use canvas::{date::PageDate, document::{TextDocument, TextPosition}, editor::{CanvasEditor, Movement, SelectionUnit, TextOutline}, layout::TextEngine}; +use onestore::page::text::{Paragraph}; use libfuzzer_sys::fuzz_target; use onestore::document::Format; use std::{cell::RefCell, collections::BTreeMap}; @@ -85,7 +80,7 @@ fuzz_target!(|input: &[u8]| { assert!(!matches!( error, canvas::editor::EditorError::Edit( - canvas::text::EditError::InvalidStructure + onestore::page::text::EditError::InvalidStructure ) )); }), @@ -199,7 +194,7 @@ fuzz_target!(|input: &[u8]| { if let Some(date) = &date { fields.push(date.source().clone()); } - let mut objects = vec![canvas::page::PageObject::Title(canvas::page::Title { + let mut objects = vec![onestore::page::PageObject::Title(onestore::page::Title { id: onestore::ExGuid::default(), layout: Default::default(), date: date_id, @@ -213,7 +208,7 @@ fuzz_target!(|input: &[u8]| { .into_iter() .enumerate() { - objects.push(canvas::page::PageObject::Image(canvas::page::Image { + objects.push(onestore::page::PageObject::Image(onestore::page::Image { id: onestore::ExGuid { n: index.try_into().unwrap(), ..Default::default() @@ -231,7 +226,7 @@ fuzz_target!(|input: &[u8]| { })); } CanvasEditor::from_page( - canvas::page::Page { + onestore::page::Page { title: String::new(), created: date.map(|date| date.timestamp()), margin_origin: [0.0; 2], -- 2.54.0