authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 22:16:29-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-10 22:16:29-07:00
logc5acf4f0209f9e28a4496e04a421a11cb40b2984
treebf923aa0cc02969958341b334a00f71fe6e15189
parent13f6c1c7a897937e5eb4c3d9260d9071200ddb54
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

22 files changed, 274 insertions(+), 2080 deletions(-)

Cargo.lock-1
...@@ -394,7 +394,6 @@ name = "canvas"...@@ -394,7 +394,6 @@ name = "canvas"
394version = "0.1.0"394version = "0.1.0"
395dependencies = [395dependencies = [
396 "bytemuck",396 "bytemuck",
397 "getrandom 0.4.3",
398 "image",397 "image",
399 "onestore",398 "onestore",
400 "parley",399 "parley",
crates/canvas/Cargo.toml-1
...@@ -13,7 +13,6 @@ parley.workspace = true...@@ -13,7 +13,6 @@ parley.workspace = true
13serde = { version = "1.0.229", features = ["derive"] }13serde = { version = "1.0.229", features = ["derive"] }
14serde_json = "1.0.151"14serde_json = "1.0.151"
15skrifa = "0.44.0"15skrifa = "0.44.0"
16getrandom = "0.4.3"
1716
18bytemuck = { version = "1.25", features = ["derive"], optional = true }17bytemuck = { version = "1.25", features = ["derive"], optional = true }
19swash = { version = "0.2.10", optional = true }18swash = { version = "0.2.10", optional = true }
crates/canvas/examples/render_page.rs+2-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1use canvas::gpu::{Renderer, Viewport, page::PageScene};1use canvas::gpu::{Renderer, Viewport, page::PageScene};
2use canvas::{layout::TextEngine, page::Page};2use canvas::layout::TextEngine;
3use onestore::page::Page;
3use onestore::{RevisionIndex, Store, document::Document};4use onestore::{RevisionIndex, Store, document::Document};
4use std::{env, fs, sync::Arc, time::Duration};5use std::{env, fs, sync::Arc, time::Duration};
56
crates/canvas/src/bin/layout_probe.rs+2-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1use canvas::{layout::TextEngine, text::Paragraph};1use canvas::layout::TextEngine;
2use onestore::document::Format;2use onestore::document::Format;
3use onestore::page::text::Paragraph;
3use serde::{Deserialize, Serialize};4use serde::{Deserialize, Serialize};
4use serde_json::json;5use serde_json::json;
5use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};6use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
crates/canvas/src/bin/page_probe.rs+2-2
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1use canvas::{1use canvas::{
2 document::TextDocument,2 document::TextDocument,
3 layout::TextEngine,3 layout::TextEngine,
4 outline::OutlineLayout,4 outline::{Arrange, OutlineLayout},
5 page::{Outline, Page, PageObject},
6};5};
6use onestore::page::{Outline, Page, PageObject};
7use onestore::{RevisionIndex, Store, document::Document};7use onestore::{RevisionIndex, Store, document::Document};
8use serde_json::json;8use serde_json::json;
9use std::{env, fs, io, sync::Arc, time::Instant};9use std::{env, fs, io, sync::Arc, time::Instant};
crates/canvas/src/date.rs+7-13
...@@ -2,18 +2,14 @@ use crate::{...@@ -2,18 +2,14 @@ use crate::{
2 document::TextDocument,2 document::TextDocument,
3 editor::EditorError,3 editor::EditorError,
4 layout::TextEngine,4 layout::TextEngine,
5 outline::{OutlineLayout, TITLE_WIDTH},5 outline::{OutlineLayout, TITLE_WIDTH, outline_layout},
6 page::{Definition, Outline, PageParagraph},
7 text::EditError,
8};6};
9use onestore::ExGuid;7use onestore::ExGuid;
8use onestore::page::text::EditError;
9use onestore::page::{Definition, Outline, PageParagraph};
10use std::collections::BTreeMap;10use std::collections::BTreeMap;
1111
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]12pub use onestore::page::DateField;
13pub enum DateField {
14 Date = 0,
15 Time = 1,
16}
1713
18#[derive(Clone)]14#[derive(Clone)]
19pub struct PageDate {15pub struct PageDate {
...@@ -85,7 +81,7 @@ impl PageDate {...@@ -85,7 +81,7 @@ impl PageDate {
85 {81 {
86 return Err(EditError::UnsupportedContent.into());82 return Err(EditError::UnsupportedContent.into());
87 }83 }
88 let layout = source.layout_with_width(engine, definitions, TITLE_WIDTH)?;84 let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?;
89 let date = Self {85 let date = Self {
90 timestamp,86 timestamp,
91 source,87 source,
...@@ -104,10 +100,8 @@ impl PageDate {...@@ -104,10 +100,8 @@ impl PageDate {
104#[cfg(test)]100#[cfg(test)]
105mod tests {101mod tests {
106 use super::*;102 use super::*;
107 use crate::{103 use crate::editor::{CanvasEditor, TextOutline};
108 editor::{CanvasEditor, TextOutline},104 use onestore::page::text::Paragraph;
109 text::Paragraph,
110 };
111105
112 #[test]106 #[test]
113 fn date_and_text_edits_share_history_without_changing_identities_or_selection() {107 fn date_and_text_edits_share_history_without_changing_identities_or_selection() {
crates/canvas/src/document.rs+88-8
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1use crate::{1use onestore::page::text::{EditError, Paragraph, new_id};
2 page::{PageParagraph, ParagraphContent, TextObject},2use onestore::page::{PageParagraph, ParagraphContent, TextObject};
3 text::{EditError, Paragraph, new_id},
4};
5use onestore::{ExGuid, document::Format};3use onestore::{ExGuid, document::Format};
6use std::{4use std::{
7 collections::{BTreeMap, BTreeSet},5 collections::{BTreeMap, BTreeSet},
...@@ -36,7 +34,7 @@ pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, Edi...@@ -36,7 +34,7 @@ pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, Edi
36 parent: None,34 parent: None,
37 level: 1,35 level: 1,
38 format,36 format,
39 content: crate::page::ParagraphContent::Text(TextObject {37 content: onestore::page::ParagraphContent::Text(TextObject {
40 date_field: None,38 date_field: None,
41 id: new_id()?,39 id: new_id()?,
42 text,40 text,
...@@ -435,8 +433,8 @@ impl TextDocument {...@@ -435,8 +433,8 @@ impl TextDocument {
435#[cfg(test)]433#[cfg(test)]
436mod tests {434mod tests {
437 use super::*;435 use super::*;
438 use crate::page::{Table, TableCell, TableColumn, TableRow};
439 use onestore::document::Format;436 use onestore::document::Format;
437 use onestore::page::{Table, TableCell, TableColumn, TableRow};
440438
441 fn position(paragraph: usize, offset: u32) -> TextPosition {439 fn position(paragraph: usize, offset: u32) -> TextPosition {
442 TextPosition { paragraph, offset }440 TextPosition { paragraph, offset }
...@@ -781,7 +779,7 @@ mod tests {...@@ -781,7 +779,7 @@ mod tests {
781 #[test]779 #[test]
782 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]780 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
783 fn imported_nodes_preserve_identity_through_edit_and_undo() {781 fn imported_nodes_preserve_identity_through_edit_and_undo() {
784 use crate::page::{Page, PageObject};782 use onestore::page::{Page, PageObject};
785 use onestore::{RevisionIndex, Store, document::Document};783 use onestore::{RevisionIndex, Store, document::Document};
786 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();784 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
787 let page = {785 let page = {
...@@ -939,7 +937,7 @@ mod tests {...@@ -939,7 +937,7 @@ mod tests {
939 assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure));937 assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure));
940 let mut unsupported = original.nodes().to_vec();938 let mut unsupported = original.nodes().to_vec();
941 unsupported[0].content =939 unsupported[0].content =
942 crate::page::ParagraphContent::Unsupported(crate::page::Unsupported {940 onestore::page::ParagraphContent::Unsupported(onestore::page::Unsupported {
943 id: new_id().unwrap(),941 id: new_id().unwrap(),
944 jcid: 0x60012,942 jcid: 0x60012,
945 layout: Default::default(),943 layout: Default::default(),
...@@ -1270,4 +1268,86 @@ mod tests {...@@ -1270,4 +1268,86 @@ mod tests {
1270 }1268 }
1271 assert!(TextDocument::new(Vec::new()).is_err());1269 assert!(TextDocument::new(Vec::new()).is_err());
1272 }1270 }
1271
1272 #[test]
1273 #[ignore = "requires CANVAS_TEST_SECTION pointing to the native Tab table capture"]
1274 fn native_table_import() {
1275 use onestore::{
1276 RevisionIndex, Store,
1277 document::Document,
1278 page::{Page, PageObject, ParagraphContent},
1279 };
1280 let page = {
1281 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
1282 let store = Store::parse(&bytes).unwrap();
1283 let index = RevisionIndex::parse(&store).unwrap();
1284 let document = Document::parse(&index).unwrap();
1285 for (space, page) in document.pages().unwrap() {
1286 let space = &document.spaces[&space];
1287 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
1288 Page::from_revision(revision, page).unwrap();
1289 }
1290 Page::from_document(&document, "rows").unwrap()
1291 };
1292 let table = page
1293 .objects
1294 .iter()
1295 .find_map(|object| match object {
1296 PageObject::Outline(outline) => {
1297 outline.paragraphs.iter().find_map(|p| match &p.content {
1298 ParagraphContent::Table(table) => Some(table),
1299 _ => None,
1300 })
1301 }
1302 _ => None,
1303 })
1304 .unwrap();
1305 assert_eq!(table.rows.len(), 3);
1306 assert_eq!(table.columns.len(), 2);
1307 assert!(table.columns.iter().all(|c| !c.locked));
1308 let cells: Vec<_> = table.rows.iter().flat_map(|row| &row.cells).collect();
1309 let text: Vec<_> = cells
1310 .iter()
1311 .map(|cell| cell.paragraphs[0].text().unwrap().text.text())
1312 .collect();
1313 assert_eq!(text, ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", ""]);
1314 assert!(cells.iter().all(|c| c.indents == [18.0, 0.0, 27.0, 27.0]));
1315 let outline = page
1316 .objects
1317 .iter()
1318 .find_map(|object| match object {
1319 PageObject::Outline(outline)
1320 if outline
1321 .paragraphs
1322 .iter()
1323 .any(|p| matches!(p.content, ParagraphContent::Table(_))) =>
1324 {
1325 Some(outline)
1326 }
1327 _ => None,
1328 })
1329 .unwrap();
1330 let mut document = TextDocument::from_nodes(outline.paragraphs.clone()).unwrap();
1331 let original = document.clone();
1332 for (index, cell) in cells.iter().enumerate() {
1333 let position = TextPosition {
1334 paragraph: index,
1335 offset: 0,
1336 };
1337 let edit = document
1338 .replace(
1339 position..position,
1340 vec![
1341 Paragraph::new("🧊".into(), Format::default()),
1342 Paragraph::new("text".into(), Format::default()),
1343 ],
1344 )
1345 .unwrap();
1346 assert_eq!(edit.container, Some(cell.id));
1347 let undo = document.apply(edit).unwrap();
1348 assert_eq!(document.paragraphs().nth(index).unwrap().text(), "🧊");
1349 document.apply(undo).unwrap();
1350 assert_eq!(document, original);
1351 }
1352 }
1273}1353}
crates/canvas/src/editor.rs+50-44
...@@ -2,11 +2,14 @@ use crate::{...@@ -2,11 +2,14 @@ use crate::{
2 date::PageDate,2 date::PageDate,
3 document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves},3 document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves},
4 layout::{LayoutError, TextEngine},4 layout::{LayoutError, TextEngine},
5 outline::{OutlineLayout, ParagraphLayout, arrange, indentation, visible_paragraphs},5 outline::{
6 page::{Definition, Outline, PageParagraph},6 Arrange, OutlineLayout, ParagraphLayout, arrange, indentation, outline_layout,
7 text::{EditError, Paragraph},7 visible_paragraphs,
8 },
8};9};
9use onestore::ExGuid;10use onestore::ExGuid;
11use onestore::page::text::{EditError, Paragraph};
12use onestore::page::{Definition, Outline, PageParagraph};
10use parley::{13use parley::{
11 Affinity, BoundingBox,14 Affinity, BoundingBox,
12 editing::{Cursor, Selection as ParagraphSelection},15 editing::{Cursor, Selection as ParagraphSelection},
...@@ -180,7 +183,7 @@ impl TextOutline {...@@ -180,7 +183,7 @@ impl TextOutline {
180 },183 },
181 )?;184 )?;
182 Ok(Self {185 Ok(Self {
183 id: crate::text::new_id()?,186 id: onestore::page::text::new_id()?,
184 title: false,187 title: false,
185 min_width: None,188 min_width: None,
186 layout: onestore::document::Layout {189 layout: onestore::document::Layout {
...@@ -227,7 +230,8 @@ impl TextOutline {...@@ -227,7 +230,8 @@ impl TextOutline {
227 {230 {
228 return Err(LayoutError::InvalidSpacing.into());231 return Err(LayoutError::InvalidSpacing.into());
229 }232 }
230 outline.layout_with_width(233 outline_layout(
234 outline,
231 engine,235 engine,
232 definitions,236 definitions,
233 outline237 outline
...@@ -399,14 +403,14 @@ impl TextOutline {...@@ -399,14 +403,14 @@ impl TextOutline {
399 continue;403 continue;
400 };404 };
401 match &node.content {405 match &node.content {
402 crate::page::ParagraphContent::Text(_) => {406 onestore::page::ParagraphContent::Text(_) => {
403 result.push(407 result.push(
404 *paragraphs408 *paragraphs
405 .get(&node.id)409 .get(&node.id)
406 .ok_or(EditError::InvalidStructure)?,410 .ok_or(EditError::InvalidStructure)?,
407 );411 );
408 }412 }
409 crate::page::ParagraphContent::Table(table) => {413 onestore::page::ParagraphContent::Table(table) => {
410 let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?;414 let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?;
411 let cells = layout415 let cells = layout
412 .cells416 .cells
...@@ -441,7 +445,7 @@ impl TextOutline {...@@ -441,7 +445,7 @@ impl TextOutline {
441 pending.push(visible_paragraphs(cell.paragraphs.iter()));445 pending.push(visible_paragraphs(cell.paragraphs.iter()));
442 }446 }
443 }447 }
444 crate::page::ParagraphContent::Unsupported(_) => {448 onestore::page::ParagraphContent::Unsupported(_) => {
445 return Err(EditError::UnsupportedContent);449 return Err(EditError::UnsupportedContent);
446 }450 }
447 }451 }
...@@ -475,14 +479,14 @@ impl TextOutline {...@@ -475,14 +479,14 @@ impl TextOutline {
475 let mut target = None;479 let mut target = None;
476 for node in visible_paragraphs(nodes.iter()) {480 for node in visible_paragraphs(nodes.iter()) {
477 let top = match &node.content {481 let top = match &node.content {
478 crate::page::ParagraphContent::Text(_) => {482 onestore::page::ParagraphContent::Text(_) => {
479 paragraphs483 paragraphs
480 .get(&node.id)484 .get(&node.id)
481 .ok_or(EditError::InvalidStructure)?485 .ok_or(EditError::InvalidStructure)?
482 .1486 .1
483 .origin[1]487 .origin[1]
484 }488 }
485 crate::page::ParagraphContent::Table(table) => {489 onestore::page::ParagraphContent::Table(table) => {
486 tables490 tables
487 .get(&table.id)491 .get(&table.id)
488 .and_then(|layout| layout.cells.first())492 .and_then(|layout| layout.cells.first())
...@@ -490,7 +494,7 @@ impl TextOutline {...@@ -490,7 +494,7 @@ impl TextOutline {
490 .rect[1]494 .rect[1]
491 - 1.86495 - 1.86
492 }496 }
493 crate::page::ParagraphContent::Unsupported(_) => {497 onestore::page::ParagraphContent::Unsupported(_) => {
494 return Err(EditError::UnsupportedContent);498 return Err(EditError::UnsupportedContent);
495 }499 }
496 };500 };
...@@ -502,7 +506,7 @@ impl TextOutline {...@@ -502,7 +506,7 @@ impl TextOutline {
502 }506 }
503 }507 }
504 let target = target.ok_or(EditError::InvalidStructure)?;508 let target = target.ok_or(EditError::InvalidStructure)?;
505 let crate::page::ParagraphContent::Table(table) = &target.content else {509 let onestore::page::ParagraphContent::Table(table) = &target.content else {
506 return paragraphs510 return paragraphs
507 .get(&target.id)511 .get(&target.id)
508 .map(|(index, _)| *index)512 .map(|(index, _)| *index)
...@@ -648,7 +652,7 @@ impl CanvasEditor {...@@ -648,7 +652,7 @@ impl CanvasEditor {
648 }652 }
649653
650 pub fn from_page(654 pub fn from_page(
651 mut page: crate::page::Page,655 mut page: onestore::page::Page,
652 engine: &mut TextEngine,656 engine: &mut TextEngine,
653 ) -> Result<Self, EditorError> {657 ) -> Result<Self, EditorError> {
654 let (objects, mut outlines, date) = page::build(&mut page, engine, true)?;658 let (objects, mut outlines, date) = page::build(&mut page, engine, true)?;
...@@ -818,7 +822,7 @@ impl CanvasEditor {...@@ -818,7 +822,7 @@ impl CanvasEditor {
818 ))822 ))
819 }823 }
820 page::Content::ReadOnly(object)824 page::Content::ReadOnly(object)
821 if !matches!(object.source, crate::page::PageObject::Title(_)) =>825 if !matches!(object.source, onestore::page::PageObject::Title(_)) =>
822 {826 {
823 Some((object.source.id(), object.source.layout(), object.rect()))827 Some((object.source.id(), object.source.layout(), object.rect()))
824 }828 }
...@@ -1329,7 +1333,7 @@ impl CanvasEditor {...@@ -1329,7 +1333,7 @@ impl CanvasEditor {
1329 paragraph: self.active_outline().source_index(index),1333 paragraph: self.active_outline().source_index(index),
1330 offset: paragraph1334 offset: paragraph
1331 .projection1335 .projection
1332 .source_offset(visible, cursor.affinity())?,1336 .source_offset(visible, crate::affinity(cursor.affinity()))?,
1333 };1337 };
1334 selection.affinities[slot] = cursor.affinity();1338 selection.affinities[slot] = cursor.affinity();
1335 }1339 }
...@@ -1579,7 +1583,7 @@ impl CanvasEditor {...@@ -1579,7 +1583,7 @@ impl CanvasEditor {
1579 } else if outline.origin()[0] >= 180.0 && trailing >= 2 {1583 } else if outline.origin()[0] >= 180.0 && trailing >= 2 {
1580 let node = &outline.document.nodes()[last.saturating_sub(trailing)];1584 let node = &outline.document.nodes()[last.saturating_sub(trailing)];
1581 let bottom = match &node.content {1585 let bottom = match &node.content {
1582 crate::page::ParagraphContent::Text(_) => {1586 onestore::page::ParagraphContent::Text(_) => {
1583 let paragraph = outline1587 let paragraph = outline
1584 .shaped1588 .shaped
1585 .paragraphs1589 .paragraphs
...@@ -1588,7 +1592,7 @@ impl CanvasEditor {...@@ -1588,7 +1592,7 @@ impl CanvasEditor {
1588 .ok_or(EditError::InvalidStructure)?;1592 .ok_or(EditError::InvalidStructure)?;
1589 f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height())1593 f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height())
1590 }1594 }
1591 crate::page::ParagraphContent::Table(table) => {1595 onestore::page::ParagraphContent::Table(table) => {
1592 let cell = outline1596 let cell = outline
1593 .shaped1597 .shaped
1594 .tables1598 .tables
...@@ -1598,7 +1602,7 @@ impl CanvasEditor {...@@ -1598,7 +1602,7 @@ impl CanvasEditor {
1598 .ok_or(EditError::InvalidStructure)?;1602 .ok_or(EditError::InvalidStructure)?;
1599 f64::from(cell.rect[3]) + 1.681603 f64::from(cell.rect[3]) + 1.68
1600 }1604 }
1601 crate::page::ParagraphContent::Unsupported(_) => {1605 onestore::page::ParagraphContent::Unsupported(_) => {
1602 return Err(EditError::UnsupportedContent.into());1606 return Err(EditError::UnsupportedContent.into());
1603 }1607 }
1604 } + f64::from(outline.origin()[1]);1608 } + f64::from(outline.origin()[1]);
...@@ -1773,7 +1777,7 @@ impl CanvasEditor {...@@ -1773,7 +1777,7 @@ impl CanvasEditor {
1773 paragraph: self.active_outline().source_index(index),1777 paragraph: self.active_outline().source_index(index),
1774 offset: paragraph1778 offset: paragraph
1775 .projection1779 .projection
1776 .source_offset(visible, next.affinity())?,1780 .source_offset(visible, crate::affinity(next.affinity()))?,
1777 };1781 };
1778 if !extend {1782 if !extend {
1779 self.active_outline_mut().selection.positions[0] = position;1783 self.active_outline_mut().selection.positions[0] = position;
...@@ -1984,12 +1988,14 @@ impl CanvasEditor {...@@ -1984,12 +1988,14 @@ impl CanvasEditor {
1984 {1988 {
1985 let visible = paragraph.projection.text();1989 let visible = paragraph.projection.text();
1986 let bytes = cluster.text_range();1990 let bytes = cluster.text_range();
1987 range.start.offset = paragraph1991 range.start.offset = paragraph.projection.source_offset(
1988 .projection1992 visible.utf16_offset(bytes.start)?,
1989 .source_offset(visible.utf16_offset(bytes.start)?, Affinity::Downstream)?;1993 onestore::page::text::Affinity::Downstream,
1990 range.end.offset = paragraph1994 )?;
1991 .projection1995 range.end.offset = paragraph.projection.source_offset(
1992 .source_offset(visible.utf16_offset(bytes.end)?, Affinity::Upstream)?;1996 visible.utf16_offset(bytes.end)?,
1997 onestore::page::text::Affinity::Upstream,
1998 )?;
1993 } else {1999 } else {
1994 let document = &self.active_outline().document;2000 let document = &self.active_outline().document;
1995 let Some(neighbor) = (if backward {2001 let Some(neighbor) = (if backward {
...@@ -2690,7 +2696,7 @@ impl CanvasEditor {...@@ -2690,7 +2696,7 @@ impl CanvasEditor {
2690 .nodes()2696 .nodes()
2691 .iter()2697 .iter()
2692 .chain(&edit.replacement)2698 .chain(&edit.replacement)
2693 .any(|node| matches!(node.content, crate::page::ParagraphContent::Table(_)))2699 .any(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_)))
2694 {2700 {
2695 let sources = outline2701 let sources = outline
2696 .document2702 .document
...@@ -2891,10 +2897,8 @@ mod tests {...@@ -2891,10 +2897,8 @@ mod tests {
2891 use onestore::document::Format;2897 use onestore::document::Format;
28922898
2893 fn table_editor(engine: &mut TextEngine) -> CanvasEditor {2899 fn table_editor(engine: &mut TextEngine) -> CanvasEditor {
2894 use crate::{2900 use onestore::page::text::new_id;
2895 page::{ParagraphContent, Table, TableCell, TableColumn, TableRow},2901 use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
2896 text::new_id,
2897 };
2898 let mut editor = CanvasEditor::new(2902 let mut editor = CanvasEditor::new(
2899 engine,2903 engine,
2900 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),2904 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
...@@ -2975,7 +2979,8 @@ mod tests {...@@ -2975,7 +2979,8 @@ mod tests {
2975 fn structured_construction_and_resize_preserve_cells_and_enclose_locked_columns() {2979 fn structured_construction_and_resize_preserve_cells_and_enclose_locked_columns() {
2976 let mut engine = TextEngine::default();2980 let mut engine = TextEngine::default();
2977 let mut source = table_editor(&mut engine).active_outline().snapshot();2981 let mut source = table_editor(&mut engine).active_outline().snapshot();
2978 let crate::page::ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {2982 let onestore::page::ParagraphContent::Table(table) = &mut source.paragraphs[0].content
2983 else {
2979 panic!()2984 panic!()
2980 };2985 };
2981 table.rows[0].cells[0].paragraphs[0]2986 table.rows[0].cells[0].paragraphs[0]
...@@ -3064,7 +3069,7 @@ mod tests {...@@ -3064,7 +3069,7 @@ mod tests {
30643069
3065 #[test]3070 #[test]
3066 fn nested_table_extents_remain_reachable_beyond_the_parent_columns() {3071 fn nested_table_extents_remain_reachable_beyond_the_parent_columns() {
3067 use crate::page::ParagraphContent;3072 use onestore::page::ParagraphContent;
3068 let mut engine = TextEngine::default();3073 let mut engine = TextEngine::default();
3069 let mut source = table_editor(&mut engine).active_outline().snapshot();3074 let mut source = table_editor(&mut engine).active_outline().snapshot();
3070 let mut nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();3075 let mut nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();
...@@ -3215,7 +3220,8 @@ mod tests {...@@ -3215,7 +3220,8 @@ mod tests {
32153220
3216 #[test]3221 #[test]
3217 fn nested_table_mouse_targets_resolve_the_source_cell() {3222 fn nested_table_mouse_targets_resolve_the_source_cell() {
3218 use crate::{page::ParagraphContent, text::new_id};3223 use onestore::page::ParagraphContent;
3224 use onestore::page::text::new_id;
3219 let mut engine = TextEngine::default();3225 let mut engine = TextEngine::default();
3220 let nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();3226 let nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();
3221 let mut editor = table_editor(&mut engine);3227 let mut editor = table_editor(&mut engine);
...@@ -3403,7 +3409,7 @@ mod tests {...@@ -3403,7 +3409,7 @@ mod tests {
34033409
3404 #[test]3410 #[test]
3405 fn overflowing_cell_caret_moves_to_the_next_rows_visual_column() {3411 fn overflowing_cell_caret_moves_to_the_next_rows_visual_column() {
3406 use crate::page::ParagraphContent;3412 use onestore::page::ParagraphContent;
3407 let mut engine = TextEngine::default();3413 let mut engine = TextEngine::default();
3408 let mut source = table_editor(&mut engine).active_outline().snapshot();3414 let mut source = table_editor(&mut engine).active_outline().snapshot();
3409 let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {3415 let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {
...@@ -3804,7 +3810,7 @@ mod tests {...@@ -3804,7 +3810,7 @@ mod tests {
38043810
3805 #[test]3811 #[test]
3806 fn column_width_edits_reflow_and_restore_structured_drafts() {3812 fn column_width_edits_reflow_and_restore_structured_drafts() {
3807 use crate::page::ParagraphContent;3813 use onestore::page::ParagraphContent;
3808 let mut engine = TextEngine::default();3814 let mut engine = TextEngine::default();
3809 for draft in [false, true] {3815 for draft in [false, true] {
3810 let mut editor = table_editor(&mut engine);3816 let mut editor = table_editor(&mut engine);
...@@ -4193,7 +4199,7 @@ mod tests {...@@ -4193,7 +4199,7 @@ mod tests {
41934199
4194 #[test]4200 #[test]
4195 fn cross_outline_validation_includes_table_row_and_cell_ids() {4201 fn cross_outline_validation_includes_table_row_and_cell_ids() {
4196 use crate::page::ParagraphContent;4202 use onestore::page::ParagraphContent;
4197 let mut engine = TextEngine::default();4203 let mut engine = TextEngine::default();
4198 let first = table_editor(&mut engine).active_outline().clone();4204 let first = table_editor(&mut engine).active_outline().clone();
4199 let second = table_editor(&mut engine).active_outline().clone();4205 let second = table_editor(&mut engine).active_outline().clone();
...@@ -4225,7 +4231,7 @@ mod tests {...@@ -4225,7 +4231,7 @@ mod tests {
42254231
4226 #[test]4232 #[test]
4227 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {4233 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {
4228 use crate::page::{Image, Page, PageObject, Title};4234 use onestore::page::{Image, Page, PageObject, Title};
4229 let mut engine = TextEngine::default();4235 let mut engine = TextEngine::default();
4230 let mut title = TextOutline::new(4236 let mut title = TextOutline::new(
4231 &mut engine,4237 &mut engine,
...@@ -4254,7 +4260,7 @@ mod tests {...@@ -4254,7 +4260,7 @@ mod tests {
4254 (120.0, 80.0, true),4260 (120.0, 80.0, true),
4255 ]4261 ]
4256 .map(|(x, y, background)| Image {4262 .map(|(x, y, background)| Image {
4257 id: crate::text::new_id().unwrap(),4263 id: onestore::page::text::new_id().unwrap(),
4258 layout: onestore::document::Layout {4264 layout: onestore::document::Layout {
4259 x: Some(x),4265 x: Some(x),
4260 y: Some(y),4266 y: Some(y),
...@@ -4272,7 +4278,7 @@ mod tests {...@@ -4272,7 +4278,7 @@ mod tests {
4272 .map(|image| image.bytes.as_ref().unwrap().clone());4278 .map(|image| image.bytes.as_ref().unwrap().clone());
4273 let mut objects = vec![4279 let mut objects = vec![
4274 PageObject::Title(Title {4280 PageObject::Title(Title {
4275 id: crate::text::new_id().unwrap(),4281 id: onestore::page::text::new_id().unwrap(),
4276 date: None,4282 date: None,
4277 layout: Default::default(),4283 layout: Default::default(),
4278 outlines: vec![title],4284 outlines: vec![title],
...@@ -5597,7 +5603,7 @@ mod tests {...@@ -5597,7 +5603,7 @@ mod tests {
55975603
5598 #[test]5604 #[test]
5599 fn native_outline_omits_outer_paragraph_spacing_through_split_and_undo() {5605 fn native_outline_omits_outer_paragraph_spacing_through_split_and_undo() {
5600 use crate::page::{Page, PageObject};5606 use onestore::page::{Page, PageObject};
5601 use onestore::{RevisionIndex, Store, document::Document};5607 use onestore::{RevisionIndex, Store, document::Document};
5602 let store = Store::parse(include_bytes!(5608 let store = Store::parse(include_bytes!(
5603 "../../../resources/canvas/baseline-anchors.one"5609 "../../../resources/canvas/baseline-anchors.one"
...@@ -5740,7 +5746,7 @@ mod tests {...@@ -5740,7 +5746,7 @@ mod tests {
5740 let outline = Outline {5746 let outline = Outline {
5741 title: false,5747 title: false,
5742 min_width: None,5748 min_width: None,
5743 id: crate::text::new_id().unwrap(),5749 id: onestore::page::text::new_id().unwrap(),
5744 layout: onestore::document::Layout {5750 layout: onestore::document::Layout {
5745 max_width: Some(220.0),5751 max_width: Some(220.0),
5746 reserved_width: Some(180.0),5752 reserved_width: Some(180.0),
...@@ -5812,7 +5818,7 @@ mod tests {...@@ -5812,7 +5818,7 @@ mod tests {
5812 nodes[2].collapsed = true;5818 nodes[2].collapsed = true;
5813 nodes[3].parent = Some(nodes[2].id);5819 nodes[3].parent = Some(nodes[2].id);
5814 nodes[3].level = 3;5820 nodes[3].level = 3;
5815 let marker = crate::text::new_id().unwrap();5821 let marker = onestore::page::text::new_id().unwrap();
5816 nodes[1].lists.push(marker);5822 nodes[1].lists.push(marker);
5817 let definitions = BTreeMap::from([(5823 let definitions = BTreeMap::from([(
5818 marker,5824 marker,
...@@ -5832,7 +5838,7 @@ mod tests {...@@ -5832,7 +5838,7 @@ mod tests {
5832 let outline = Outline {5838 let outline = Outline {
5833 title: false,5839 title: false,
5834 min_width: None,5840 min_width: None,
5835 id: crate::text::new_id().unwrap(),5841 id: onestore::page::text::new_id().unwrap(),
5836 layout: onestore::document::Layout {5842 layout: onestore::document::Layout {
5837 x: Some(30.0),5843 x: Some(30.0),
5838 y: Some(45.0),5844 y: Some(45.0),
...@@ -5963,7 +5969,7 @@ mod tests {...@@ -5963,7 +5969,7 @@ mod tests {
5963 #[test]5969 #[test]
5964 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]5970 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
5965 fn imported_editor_reflows_and_restores_native_outline_geometry() {5971 fn imported_editor_reflows_and_restores_native_outline_geometry() {
5966 use crate::page::{Page, PageObject};5972 use onestore::page::{Page, PageObject};
5967 use onestore::{RevisionIndex, Store, document::Document};5973 use onestore::{RevisionIndex, Store, document::Document};
5968 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();5974 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
5969 let page = {5975 let page = {
crates/canvas/src/editor/page.rs+4-4
...@@ -2,10 +2,10 @@ use super::{EditorError, TextOutline};...@@ -2,10 +2,10 @@ use super::{EditorError, TextOutline};
2use crate::{2use crate::{
3 date::PageDate,3 date::PageDate,
4 layout::{LayoutError, TextEngine, TextLayout},4 layout::{LayoutError, TextEngine, TextLayout},
5 outline::OutlineLayout,5 outline::{Arrange, OutlineLayout},
6 page::{Image, Outline, Page, PageObject},
7 text::Paragraph,
8};6};
7use onestore::page::text::Paragraph;
8use onestore::page::{Image, Outline, Page, PageObject};
99
10pub(crate) enum Content {10pub(crate) enum Content {
11 Date {11 Date {
...@@ -246,7 +246,7 @@ pub(crate) fn build(...@@ -246,7 +246,7 @@ pub(crate) fn build(
246 continue;246 continue;
247 }247 }
248 Err(EditorError::Edit(248 Err(EditorError::Edit(
249 crate::text::EditError::UnsupportedContent,249 onestore::page::text::EditError::UnsupportedContent,
250 )) => {}250 )) => {}
251 Err(error) => return Err(error),251 Err(error) => return Err(error),
252 }252 }
crates/canvas/src/editor/table.rs+3-3
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1use super::*;1use super::*;
2use crate::page::{ParagraphContent, TableCell, TableColumn, TableRow};2use onestore::page::text::new_id;
3use crate::text::new_id;3use onestore::page::{ParagraphContent, TableCell, TableColumn, TableRow};
44
5struct CellLocation<'a> {5struct CellLocation<'a> {
6 container: Option<ExGuid>,6 container: Option<ExGuid>,
...@@ -125,7 +125,7 @@ impl CanvasEditor {...@@ -125,7 +125,7 @@ impl CanvasEditor {
125 lists: Vec::new(),125 lists: Vec::new(),
126 tags: Vec::new(),126 tags: Vec::new(),
127 collapsed: false,127 collapsed: false,
128 content: ParagraphContent::Table(crate::page::Table {128 content: ParagraphContent::Table(onestore::page::Table {
129 id: new_id()?,129 id: new_id()?,
130 columns: vec![130 columns: vec![
131 TableColumn {131 TableColumn {
crates/canvas/src/gpu/mod.rs+2-1
...@@ -1019,8 +1019,9 @@ pub fn colorref(color: u32) -> [f32; 4] {...@@ -1019,8 +1019,9 @@ pub fn colorref(color: u32) -> [f32; 4] {
1019#[cfg(test)]1019#[cfg(test)]
1020mod tests {1020mod tests {
1021 use super::*;1021 use super::*;
1022 use crate::{layout::TextEngine, text::Paragraph};1022 use crate::layout::TextEngine;
1023 use onestore::document::Format as TextFormat;1023 use onestore::document::Format as TextFormat;
1024 use onestore::page::text::Paragraph;
1024 use std::time::Duration;1025 use std::time::Duration;
10251026
1026 #[test]1027 #[test]
crates/canvas/src/gpu/page.rs+20-21
...@@ -5,8 +5,8 @@ use crate::{...@@ -5,8 +5,8 @@ use crate::{
5 date::DateField,5 date::DateField,
6 editor::{CanvasEditor, EditorError},6 editor::{CanvasEditor, EditorError},
7 layout::{LayoutError, TextEngine},7 layout::{LayoutError, TextEngine},
8 page::Page,
9};8};
9use onestore::page::Page;
10use std::fmt;10use std::fmt;
1111
12/// Retained drawing data in the source page's coordinate system.12/// Retained drawing data in the source page's coordinate system.
...@@ -519,17 +519,16 @@ impl crate::outline::OutlineLayout {...@@ -519,17 +519,16 @@ impl crate::outline::OutlineLayout {
519#[cfg(test)]519#[cfg(test)]
520mod tests {520mod tests {
521 use super::*;521 use super::*;
522 use crate::{522 use crate::editor::TextOutline;
523 editor::TextOutline,523 use onestore::page::text::Paragraph;
524 page::{Image, PageObject},524 use onestore::page::{Image, PageObject};
525 text::Paragraph,
526 };
527 use onestore::{ExGuid, document::Layout};525 use onestore::{ExGuid, document::Layout};
528 use std::{collections::BTreeMap, sync::Arc};526 use std::{collections::BTreeMap, sync::Arc};
529527
530 #[test]528 #[test]
531 fn title_editing_keeps_identity_and_date_geometry_through_composition_and_undo() {529 fn title_editing_keeps_identity_and_date_geometry_through_composition_and_undo() {
532 use crate::{document::TextDocument, page::Title};530 use crate::document::TextDocument;
531 use onestore::page::Title;
533 let mut engine = TextEngine::default();532 let mut engine = TextEngine::default();
534 let mut fields = ["Header", "September 8, 2026"].map(|text| {533 let mut fields = ["Header", "September 8, 2026"].map(|text| {
535 let document =534 let document =
...@@ -740,7 +739,7 @@ mod tests {...@@ -740,7 +739,7 @@ mod tests {
740 vec![outline],739 vec![outline],
741 BTreeMap::from([(740 BTreeMap::from([(
742 style,741 style,
743 crate::page::Definition {742 onestore::page::Definition {
744 kind: onestore::document::Kind::Style {743 kind: onestore::document::Kind::Style {
745 name: Some("p".into()),744 name: Some("p".into()),
746 },745 },
...@@ -797,7 +796,8 @@ mod tests {...@@ -797,7 +796,8 @@ mod tests {
797 #[test]796 #[test]
798 #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"]797 #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"]
799 fn native_title_flow() {798 fn native_title_flow() {
800 use crate::{document::TextDocument, page::Image};799 use crate::document::TextDocument;
800 use onestore::page::Image;
801 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();801 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
802 let store = onestore::Store::parse(&bytes).unwrap();802 let store = onestore::Store::parse(&bytes).unwrap();
803 let index = onestore::RevisionIndex::parse(&store).unwrap();803 let index = onestore::RevisionIndex::parse(&store).unwrap();
...@@ -906,7 +906,7 @@ mod tests {...@@ -906,7 +906,7 @@ mod tests {
906 (120.0, 60.0, false),906 (120.0, 60.0, false),
907 ] {907 ] {
908 page.objects.push(PageObject::Image(Image {908 page.objects.push(PageObject::Image(Image {
909 id: crate::text::new_id().unwrap(),909 id: onestore::page::text::new_id().unwrap(),
910 bytes: Some(image_bytes.clone()),910 bytes: Some(image_bytes.clone()),
911 layout: Layout {911 layout: Layout {
912 x: Some(x),912 x: Some(x),
...@@ -961,7 +961,8 @@ mod tests {...@@ -961,7 +961,8 @@ mod tests {
961961
962 #[test]962 #[test]
963 fn title_exit_places_an_uncommitted_body_caret_below_the_date() {963 fn title_exit_places_an_uncommitted_body_caret_below_the_date() {
964 use crate::{document::TextDocument, page::Title};964 use crate::document::TextDocument;
965 use onestore::page::Title;
965 let mut engine = TextEngine::default();966 let mut engine = TextEngine::default();
966 let fields =967 let fields =
967 [("Header", 20.751953), ("Date\u{000b}Time", 12.207031)].map(|(text, line_spacing)| {968 [("Header", 20.751953), ("Date\u{000b}Time", 12.207031)].map(|(text, line_spacing)| {
...@@ -1040,7 +1041,7 @@ mod tests {...@@ -1040,7 +1041,7 @@ mod tests {
1040 let objects = [0.0, 20.0]1041 let objects = [0.0, 20.0]
1041 .into_iter()1042 .into_iter()
1042 .map(|position| {1043 .map(|position| {
1043 PageObject::Unsupported(crate::page::Unsupported {1044 PageObject::Unsupported(onestore::page::Unsupported {
1044 id: ExGuid {1045 id: ExGuid {
1045 guid: [position as u8; 16],1046 guid: [position as u8; 16],
1046 n: 1,1047 n: 1,
...@@ -1080,11 +1081,9 @@ mod tests {...@@ -1080,11 +1081,9 @@ mod tests {
10801081
1081 #[test]1082 #[test]
1082 fn unsupported_tags_and_titles_leave_supported_outlines_editable() {1083 fn unsupported_tags_and_titles_leave_supported_outlines_editable() {
1083 use crate::{1084 use crate::document::TextDocument;
1084 document::TextDocument,
1085 page::{Definition, Outline, Title},
1086 };
1087 use onestore::document::{Kind, Tag};1085 use onestore::document::{Kind, Tag};
1086 use onestore::page::{Definition, Outline, Title};
1088 for title in [false, true] {1087 for title in [false, true] {
1089 let definition = ExGuid {1088 let definition = ExGuid {
1090 guid: [9; 16],1089 guid: [9; 16],
...@@ -1190,10 +1189,8 @@ mod tests {...@@ -1190,10 +1189,8 @@ mod tests {
11901189
1191 #[test]1190 #[test]
1192 fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() {1191 fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() {
1193 use crate::{1192 use crate::document::TextDocument;
1194 document::TextDocument,1193 use onestore::page::{Outline, Unsupported};
1195 page::{Outline, Unsupported},
1196 };
1197 let document = TextDocument::new(vec![Paragraph::new(1194 let document = TextDocument::new(vec![Paragraph::new(
1198 "preserved text".into(),1195 "preserved text".into(),
1199 Default::default(),1196 Default::default(),
...@@ -1347,7 +1344,9 @@ mod tests {...@@ -1347,7 +1344,9 @@ mod tests {
13471344
1348 #[test]1345 #[test]
1349 fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() {1346 fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() {
1350 use crate::{document::TextDocument, page::Outline, text::Paragraph};1347 use crate::document::TextDocument;
1348 use onestore::page::Outline;
1349 use onestore::page::text::Paragraph;
1351 let outline = |x, text: &str| {1350 let outline = |x, text: &str| {
1352 let document =1351 let document =
1353 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap();1352 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap();
crates/canvas/src/gpu/profile.rs+3-1
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1use super::*;1use super::*;
2use crate::{layout::TextEngine, page::Page, text::Paragraph};2use crate::layout::TextEngine;
3use onestore::page::Page;
4use onestore::page::text::Paragraph;
3use std::{5use std::{
4 sync::Arc,6 sync::Arc,
5 time::{Duration, Instant},7 time::{Duration, Instant},
crates/canvas/src/layout.rs+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use crate::text::Paragraph;1use onestore::page::text::Paragraph;
2use parley::{2use parley::{
3 Affinity, Alignment, AlignmentOptions, BoundingBox, FontContext, FontFamily, FontStyle,3 Affinity, Alignment, AlignmentOptions, BoundingBox, FontContext, FontFamily, FontStyle,
4 FontWeight, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty,4 FontWeight, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty,
crates/canvas/src/lib.rs+8-2
...@@ -2,9 +2,15 @@ pub mod document;...@@ -2,9 +2,15 @@ pub mod document;
2pub mod editor;2pub mod editor;
3pub mod layout;3pub mod layout;
4pub mod outline;4pub mod outline;
5pub mod page;
6pub mod text;
75
8pub mod date;6pub mod date;
9#[cfg(feature = "gpu")]7#[cfg(feature = "gpu")]
10pub mod gpu;8pub mod gpu;
9
10/// Parley's caret affinity mapped onto the page model's hidden-field affinity.
11pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity {
12 match affinity {
13 parley::Affinity::Upstream => onestore::page::text::Affinity::Upstream,
14 parley::Affinity::Downstream => onestore::page::text::Affinity::Downstream,
15 }
16}
crates/canvas/src/outline.rs+62-48
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1use crate::{1use crate::{
2 document::{DocumentEdit, edited_nodes},2 document::{DocumentEdit, edited_nodes},
3 layout::{LayoutError, TextEngine, TextLayout},3 layout::{LayoutError, TextEngine, TextLayout},
4 page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title},
5 text::{Paragraph, TextProjection},
6};4};
5use onestore::page::text::{Paragraph, TextProjection};
6use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title};
7use onestore::{7use onestore::{
8 ExGuid,8 ExGuid,
9 document::{Format, Kind},9 document::{Format, Kind},
...@@ -498,8 +498,20 @@ impl OutlineLayout {...@@ -498,8 +498,20 @@ impl OutlineLayout {
498 }498 }
499}499}
500500
501impl Outline {501/// Shapes a page object into positioned paragraph layouts.
502 pub fn layout(502pub trait Arrange {
503 type Output;
504 fn layout(
505 &self,
506 engine: &mut TextEngine,
507 definitions: &BTreeMap<ExGuid, Definition>,
508 ) -> Result<Self::Output, LayoutError>;
509}
510
511impl Arrange for Outline {
512 type Output = OutlineLayout;
513
514 fn layout(
503 &self,515 &self,
504 engine: &mut TextEngine,516 engine: &mut TextEngine,
505 definitions: &BTreeMap<ExGuid, Definition>,517 definitions: &BTreeMap<ExGuid, Definition>,
...@@ -509,51 +521,53 @@ impl Outline {...@@ -509,51 +521,53 @@ impl Outline {
509 .reserved_width521 .reserved_width
510 .or(self.layout.max_width)522 .or(self.layout.max_width)
511 .ok_or(LayoutError::InvalidWidth)?;523 .ok_or(LayoutError::InvalidWidth)?;
512 self.layout_with_width(engine, definitions, width)524 outline_layout(self, engine, definitions, width)
513 }525 }
526}
514527
515 pub(crate) fn layout_with_width(528pub(crate) fn outline_layout(
516 &self,529 outline: &Outline,
517 engine: &mut TextEngine,530 engine: &mut TextEngine,
518 definitions: &BTreeMap<ExGuid, Definition>,531 definitions: &BTreeMap<ExGuid, Definition>,
519 width: f32,532 width: f32,
520 ) -> Result<OutlineLayout, LayoutError> {533) -> Result<OutlineLayout, LayoutError> {
521 if self.indents.len() < 2 || self.indents.iter().any(|v| !v.is_finite() || *v < 0.0) {534 if outline.indents.len() < 2 || outline.indents.iter().any(|v| !v.is_finite() || *v < 0.0) {
522 return Err(LayoutError::InvalidIndentation);535 return Err(LayoutError::InvalidIndentation);
523 }536 }
524 if !self.unsupported.is_empty() {537 if !outline.unsupported.is_empty() {
525 return Err(LayoutError::UnsupportedContent);538 return Err(LayoutError::UnsupportedContent);
526 }539 }
527 if self540 if outline
528 .paragraphs541 .paragraphs
529 .iter()542 .iter()
530 .any(|node| matches!(node.content, ParagraphContent::Table(_)))543 .any(|node| matches!(node.content, ParagraphContent::Table(_)))
531 {544 {
532 return OutlineLayout::flow(545 return OutlineLayout::flow(
533 self.paragraphs.iter(),546 outline.paragraphs.iter(),
534 &self.indents,547 &outline.indents,
535 width,
536 self.layout.width_set_by_user == Some(true),
537 0,
538 None,
539 &mut |node, width, indents| {
540 ParagraphLayout::shape(engine, node, width, indents, definitions)
541 },
542 );
543 }
544 let paragraphs = visible_paragraphs(self.paragraphs.iter())
545 .map(|p| ParagraphLayout::shape(engine, p, width, &self.indents, definitions))
546 .collect::<Result<_, _>>()?;
547 OutlineLayout::new(
548 paragraphs,
549 width,548 width,
550 self.layout.width_set_by_user == Some(true),549 outline.layout.width_set_by_user == Some(true),
551 )550 0,
551 None,
552 &mut |node, width, indents| {
553 ParagraphLayout::shape(engine, node, width, indents, definitions)
554 },
555 );
552 }556 }
557 let paragraphs = visible_paragraphs(outline.paragraphs.iter())
558 .map(|p| ParagraphLayout::shape(engine, p, width, &outline.indents, definitions))
559 .collect::<Result<_, _>>()?;
560 OutlineLayout::new(
561 paragraphs,
562 width,
563 outline.layout.width_set_by_user == Some(true),
564 )
553}565}
554566
555impl Title {567impl Arrange for Title {
556 pub fn layout(568 type Output = Vec<([f32; 2], OutlineLayout)>;
569
570 fn layout(
557 &self,571 &self,
558 engine: &mut TextEngine,572 engine: &mut TextEngine,
559 definitions: &BTreeMap<ExGuid, Definition>,573 definitions: &BTreeMap<ExGuid, Definition>,
...@@ -573,7 +587,7 @@ impl Title {...@@ -573,7 +587,7 @@ impl Title {
573 .reserved_width587 .reserved_width
574 .or(outline.layout.max_width)588 .or(outline.layout.max_width)
575 .unwrap_or(TITLE_WIDTH);589 .unwrap_or(TITLE_WIDTH);
576 let layout = outline.layout_with_width(engine, definitions, width)?;590 let layout = outline_layout(outline, engine, definitions, width)?;
577 let origin = [591 let origin = [
578 outline.layout.x.unwrap_or(0.0),592 outline.layout.x.unwrap_or(0.0),
579 bottom + outline.layout.y.unwrap_or(0.0),593 bottom + outline.layout.y.unwrap_or(0.0),
...@@ -595,8 +609,8 @@ impl Title {...@@ -595,8 +609,8 @@ impl Title {
595#[cfg(test)]609#[cfg(test)]
596mod tests {610mod tests {
597 use super::*;611 use super::*;
598 use crate::page::{PageParagraph, TextObject};
599 use onestore::document::Layout;612 use onestore::document::Layout;
613 use onestore::page::{PageParagraph, TextObject};
600614
601 fn paragraph(n: u32, text: &str, level: u32, parent: Option<u32>) -> PageParagraph {615 fn paragraph(n: u32, text: &str, level: u32, parent: Option<u32>) -> PageParagraph {
602 PageParagraph {616 PageParagraph {
...@@ -610,7 +624,7 @@ mod tests {...@@ -610,7 +624,7 @@ mod tests {
610 }),624 }),
611 level,625 level,
612 format: Format::default(),626 format: Format::default(),
613 content: crate::page::ParagraphContent::Text(TextObject {627 content: onestore::page::ParagraphContent::Text(TextObject {
614 date_field: None,628 date_field: None,
615 id: ExGuid {629 id: ExGuid {
616 n: n + 100,630 n: n + 100,
...@@ -627,7 +641,7 @@ mod tests {...@@ -627,7 +641,7 @@ mod tests {
627 }641 }
628642
629 fn table(rows: &[&[&str]], widths: &[f32], mut n: u32) -> PageParagraph {643 fn table(rows: &[&[&str]], widths: &[f32], mut n: u32) -> PageParagraph {
630 use crate::page::{TableCell, TableColumn, TableRow};644 use onestore::page::{TableCell, TableColumn, TableRow};
631 let mut id = || {645 let mut id = || {
632 n += 1;646 n += 1;
633 ExGuid {647 ExGuid {
...@@ -816,7 +830,7 @@ mod tests {...@@ -816,7 +830,7 @@ mod tests {
816 #[test]830 #[test]
817 #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"]831 #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"]
818 fn native_table_layout() {832 fn native_table_layout() {
819 use crate::page::{Page, PageObject};833 use onestore::page::{Page, PageObject};
820 use onestore::{RevisionIndex, Store, document::Document};834 use onestore::{RevisionIndex, Store, document::Document};
821 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();835 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
822 let store = Store::parse(&bytes).unwrap();836 let store = Store::parse(&bytes).unwrap();
crates/canvas/src/page.rs deleted-1337
...@@ -1,1337 +0,0 @@
1use crate::date::DateField;
2use crate::text::Paragraph;
3use onestore::{
4 ExGuid,
5 document::{Document, Format, Kind, Layout, Revision, Tag},
6};
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 sync::Arc,
10};
11
12pub struct Page {
13 pub title: String,
14 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
15 pub created: Option<u64>,
16 pub margin_origin: [f32; 2],
17 pub objects: Vec<PageObject>,
18 pub definitions: BTreeMap<ExGuid, Definition>,
19}
20
21pub struct Definition {
22 pub kind: Kind<'static>,
23 pub format: Format,
24}
25
26pub enum PageObject {
27 Outline(Outline),
28 Title(Title),
29 Image(Image),
30 Unsupported(Unsupported),
31}
32
33impl PageObject {
34 pub(crate) fn id(&self) -> ExGuid {
35 match self {
36 Self::Outline(value) => value.id,
37 Self::Title(value) => value.id,
38 Self::Image(value) => value.id,
39 Self::Unsupported(value) => value.id,
40 }
41 }
42 pub(crate) fn layout(&self) -> &Layout {
43 match self {
44 Self::Outline(value) => &value.layout,
45 Self::Title(value) => &value.layout,
46 Self::Image(value) => &value.layout,
47 Self::Unsupported(value) => &value.layout,
48 }
49 }
50
51 pub(crate) fn layout_mut(&mut self) -> &mut Layout {
52 match self {
53 Self::Outline(value) => &mut value.layout,
54 Self::Title(value) => &mut value.layout,
55 Self::Image(value) => &mut value.layout,
56 Self::Unsupported(value) => &mut value.layout,
57 }
58 }
59}
60
61pub struct Title {
62 pub id: ExGuid,
63 pub date: Option<ExGuid>,
64 pub layout: Layout,
65 pub outlines: Vec<Outline>,
66}
67
68#[derive(Clone)]
69pub struct Outline {
70 pub id: ExGuid,
71 pub title: bool,
72 pub min_width: Option<f32>,
73 pub layout: Layout,
74 pub indents: Vec<f32>,
75 pub paragraphs: Vec<PageParagraph>,
76 pub unsupported: Vec<Unsupported>,
77}
78
79#[derive(Clone, Debug, PartialEq)]
80pub struct PageParagraph {
81 pub id: ExGuid,
82 pub parent: Option<ExGuid>,
83 pub level: u32,
84 pub style: Option<ExGuid>,
85 pub format: Format,
86 pub content: ParagraphContent,
87 pub lists: Vec<ExGuid>,
88 pub tags: Vec<Tag>,
89 pub collapsed: bool,
90}
91
92#[derive(Clone, Debug, PartialEq)]
93pub enum ParagraphContent {
94 Text(TextObject),
95 Table(Table),
96 Unsupported(Unsupported),
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct Table {
101 pub id: ExGuid,
102 pub columns: Vec<TableColumn>,
103 pub rows: Vec<TableRow>,
104 pub borders: Option<bool>,
105 pub layout: Layout,
106 pub tags: Vec<Tag>,
107}
108
109#[derive(Clone, Debug, PartialEq)]
110pub struct TableColumn {
111 pub width: f32,
112 pub locked: bool,
113}
114
115#[derive(Clone, Debug, PartialEq)]
116pub struct TableRow {
117 pub id: ExGuid,
118 pub cells: Vec<TableCell>,
119}
120
121#[derive(Clone, Debug, PartialEq)]
122pub struct TableCell {
123 pub id: ExGuid,
124 pub layout: Layout,
125 pub indents: Vec<f32>,
126 pub shading: Option<u32>,
127 pub paragraphs: Vec<PageParagraph>,
128 pub unsupported: Vec<Unsupported>,
129}
130
131#[derive(Clone, Debug, PartialEq)]
132pub struct TextObject {
133 pub id: ExGuid,
134 pub date_field: Option<DateField>,
135 pub text: Paragraph,
136 pub tags: Vec<Tag>,
137}
138
139pub struct Image {
140 pub id: ExGuid,
141 pub layout: Layout,
142 pub bytes: Option<Arc<[u8]>>,
143 pub alt: Option<String>,
144 pub background: bool,
145}
146
147#[derive(Clone, Debug, PartialEq)]
148pub struct Unsupported {
149 pub id: ExGuid,
150 pub jcid: u32,
151 pub layout: Layout,
152}
153
154impl Page {
155 pub fn from_document(document: &Document<'_>, title: &str) -> Result<Self, onestore::Error> {
156 let mut selected = None;
157 for (space, id) in document.pages()? {
158 let space = &document.spaces[&space];
159 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
160 if page_title(revision, id) == Some(title) {
161 if selected.is_some() {
162 return Err(onestore::Error {
163 offset: 0,
164 message: "More than one active page has the requested title",
165 });
166 }
167 selected = Some((revision, id));
168 }
169 }
170 let (revision, id) = selected.ok_or(onestore::Error {
171 offset: 0,
172 message: "No active page has the requested title",
173 })?;
174 Self::from_revision(revision, id)
175 }
176
177 pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result<Self, onestore::Error> {
178 let invalid = |message| onestore::Error { offset: 0, message };
179 let root = revision
180 .nodes
181 .get(&id)
182 .ok_or_else(|| invalid("Missing canvas page"))?;
183 let Kind::Page {
184 margin_origin_x,
185 margin_origin_y,
186 ..
187 } = &root.kind
188 else {
189 return Err(invalid("Canvas root is not a page"));
190 };
191 let mut page = Self {
192 title: page_title(revision, id).unwrap_or_default().to_owned(),
193 created: revision
194 .roots
195 .get(&2)
196 .and_then(|id| revision.nodes.get(id))
197 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))
198 .and_then(|node| node.extra.first())
199 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
200 .map(|field| {
201 let onestore::document::FieldValue::Bytes(bytes) = field.value else {
202 return Err(invalid("The page creation date is damaged"));
203 };
204 Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| {
205 invalid("The page creation date is damaged")
206 })?))
207 })
208 .transpose()?,
209 margin_origin: [
210 margin_origin_x.unwrap_or(0.0),
211 margin_origin_y.unwrap_or(0.0),
212 ],
213 objects: Vec::new(),
214 definitions: BTreeMap::new(),
215 };
216 let mut roots: Vec<_> = root
217 .children
218 .iter()
219 .chain(&root.structure)
220 .rev()
221 .map(|id| (*id, None))
222 .collect();
223 let mut seen = BTreeSet::new();
224 while let Some((id, title_index)) = roots.pop() {
225 if !seen.insert(id) {
226 return Err(invalid("Repeated canvas page object"));
227 }
228 let node = revision
229 .nodes
230 .get(&id)
231 .ok_or_else(|| invalid("Missing canvas page object"))?;
232 if title_index.is_some() && !matches!(node.kind, Kind::Outline { .. }) {
233 return Err(invalid("Canvas title child is not an outline"));
234 }
235 match &node.kind {
236 Kind::Title => {
237 let index = page.objects.len();
238 page.objects.push(PageObject::Title(Title {
239 id,
240 date: None,
241 layout: node.layout.clone(),
242 outlines: Vec::new(),
243 }));
244 roots.extend(node.children.iter().rev().map(|id| (*id, Some(index))));
245 }
246 Kind::Outline { indents } => {
247 let fields = node.extra.first().map(Vec::as_slice).unwrap_or_default();
248 if let Some(index) = title_index
249 && fields.iter().any(|field| {
250 field.id == 0x88001cb5
251 && matches!(field.value, onestore::document::FieldValue::NoData)
252 })
253 {
254 let PageObject::Title(title) = &mut page.objects[index] else {
255 unreachable!()
256 };
257 if title.date.replace(id).is_some() {
258 return Err(invalid("The page has more than one date field"));
259 }
260 }
261 let min_width = fields
262 .iter()
263 .find(|field| field.id == 0x14001cec)
264 .map(|field| {
265 let onestore::document::FieldValue::Bytes(bytes) = field.value else {
266 return Err(invalid("Invalid canvas minimum outline width"));
267 };
268 let width =
269 f32::from_le_bytes(bytes.try_into().map_err(|_| {
270 invalid("Invalid canvas minimum outline width")
271 })?) * 36.0;
272 if !width.is_finite() || width <= 0.0 {
273 return Err(invalid("Invalid canvas minimum outline width"));
274 }
275 Ok(width)
276 })
277 .transpose()?;
278 let mut outline = Outline {
279 id,
280 title: fields.iter().any(|field| {
281 field.id == 0x88001cb4
282 && matches!(field.value, onestore::document::FieldValue::NoData)
283 }),
284 min_width,
285 layout: node.layout.clone(),
286 indents: indents.clone(),
287 paragraphs: Vec::new(),
288 unsupported: Vec::new(),
289 };
290 (outline.paragraphs, outline.unsupported) = read_paragraphs(
291 revision,
292 node,
293 &node.format,
294 &mut page.definitions,
295 &mut seen,
296 0,
297 )?;
298 if let Some(index) = title_index {
299 let PageObject::Title(title) = &mut page.objects[index] else {
300 unreachable!()
301 };
302 title.outlines.push(outline);
303 } else {
304 page.objects.push(PageObject::Outline(outline));
305 }
306 }
307 Kind::Image {
308 container,
309 alt,
310 background,
311 ..
312 } => {
313 let bytes = if let Some(container) = container {
314 let data = revision
315 .nodes
316 .get(container)
317 .ok_or_else(|| invalid("Missing canvas image data"))?;
318 match &data.kind {
319 Kind::File { payload, .. } => payload.map(Arc::from),
320 _ => return Err(invalid("Canvas image data has the wrong type")),
321 }
322 } else {
323 None
324 };
325 page.objects.push(PageObject::Image(Image {
326 id,
327 layout: node.layout.clone(),
328 bytes,
329 alt: alt.clone(),
330 background: background.unwrap_or(false),
331 }));
332 }
333 _ => page.objects.push(PageObject::Unsupported(Unsupported {
334 id,
335 jcid: node.jcid,
336 layout: node.layout.clone(),
337 })),
338 }
339 }
340 Ok(page)
341 }
342}
343
344fn read_paragraphs(
345 revision: &Revision<'_>,
346 container: &onestore::document::Element<'_>,
347 inherited: &Format,
348 definitions: &mut BTreeMap<ExGuid, Definition>,
349 seen: &mut BTreeSet<ExGuid>,
350 depth: usize,
351) -> Result<(Vec<PageParagraph>, Vec<Unsupported>), onestore::Error> {
352 let invalid = |message| onestore::Error { offset: 0, message };
353 let default_format = Format::default();
354 let style = |id: Option<ExGuid>| -> Result<&Format, onestore::Error> {
355 let Some(id) = id else {
356 return Ok(&default_format);
357 };
358 let node = revision
359 .nodes
360 .get(&id)
361 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
362 if !matches!(node.kind, Kind::Style { .. }) {
363 return Err(invalid("Canvas paragraph style has the wrong type"));
364 }
365 Ok(&node.format)
366 };
367
368 let mut paragraphs = Vec::new();
369 let mut unsupported = Vec::new();
370 let mut pending: Vec<_> = container
371 .children
372 .iter()
373 .rev()
374 .map(|id| {
375 (
376 *id,
377 None,
378 u32::from(container.child_level.unwrap_or(0)),
379 inherited.clone(),
380 )
381 })
382 .collect();
383 while let Some((id, parent, level, inherited)) = pending.pop() {
384 if !seen.insert(id) {
385 return Err(invalid("Repeated canvas outline object"));
386 }
387 let node = revision
388 .nodes
389 .get(&id)
390 .ok_or_else(|| invalid("Missing canvas outline object"))?;
391 let next_level = level
392 .checked_add(u32::from(node.child_level.unwrap_or(0)))
393 .ok_or_else(|| invalid("Canvas outline level overflow"))?;
394 match &node.kind {
395 Kind::Paragraph {
396 lists,
397 paragraph_style,
398 collapse_state,
399 } => {
400 let format = node
401 .format
402 .inherit(style(*paragraph_style)?)
403 .inherit(&inherited);
404 let mut base_style = *paragraph_style;
405 let [content_id] = node.content.as_slice() else {
406 return Err(invalid("A paragraph must contain one content object"));
407 };
408 if !seen.insert(*content_id) {
409 return Err(invalid("Repeated canvas paragraph content"));
410 }
411 let content = revision
412 .nodes
413 .get(content_id)
414 .ok_or_else(|| invalid("Missing canvas paragraph content"))?;
415 let content = if let Kind::RichText {
416 paragraph_style, ..
417 } = &content.kind
418 {
419 base_style = paragraph_style.or(base_style);
420 let runs = revision.text_runs(*content_id)?;
421 let text_content = if runs.is_empty() {
422 Paragraph::new(
423 String::new(),
424 content
425 .format
426 .inherit(style(*paragraph_style)?)
427 .inherit(&format),
428 )
429 } else {
430 Paragraph::from_runs(
431 runs.into_iter()
432 .map(|run| (run.text.to_owned(), run.format.inherit(&format))),
433 )
434 };
435 ParagraphContent::Text(TextObject {
436 id: *content_id,
437 date_field: content
438 .extra
439 .first()
440 .map(Vec::as_slice)
441 .unwrap_or_default()
442 .iter()
443 .filter(|field| {
444 matches!(field.value, onestore::document::FieldValue::NoData)
445 })
446 .filter_map(|field| match field.id {
447 0x88001cb5 => Some(DateField::Date),
448 0x88001c87 => Some(DateField::Time),
449 _ => None,
450 })
451 .try_fold(None, |previous, field| {
452 if previous.is_some() {
453 Err(invalid("The page date field has conflicting roles"))
454 } else {
455 Ok(Some(field))
456 }
457 })?,
458 text: text_content,
459 tags: content.tags.clone(),
460 })
461 } else if matches!(content.kind, Kind::Table { .. }) {
462 ParagraphContent::Table(read_table(
463 revision,
464 *content_id,
465 &content.format.inherit(&format),
466 definitions,
467 seen,
468 depth + 1,
469 )?)
470 } else {
471 ParagraphContent::Unsupported(Unsupported {
472 id: *content_id,
473 jcid: content.jcid,
474 layout: content.layout.clone(),
475 })
476 };
477 for (id, is_list) in lists.iter().map(|id| (id, true)).chain(
478 node.tags
479 .iter()
480 .chain(match &content {
481 ParagraphContent::Text(text) => text.tags.as_slice(),
482 ParagraphContent::Table(table) => table.tags.as_slice(),
483 ParagraphContent::Unsupported(_) => &[],
484 })
485 .filter_map(|tag| tag.definition.as_ref())
486 .map(|id| (id, false)),
487 ) {
488 let definition = revision
489 .nodes
490 .get(id)
491 .ok_or_else(|| invalid("Missing canvas list or tag definition"))?;
492 let kind = match &definition.kind {
493 Kind::List {
494 font,
495 format,
496 restart,
497 bullet,
498 } if is_list => Kind::List {
499 font: font.clone(),
500 format: format.clone(),
501 restart: *restart,
502 bullet: *bullet,
503 },
504 Kind::TagDefinition {
505 label,
506 action_type,
507 shape,
508 color,
509 highlight,
510 } if !is_list => Kind::TagDefinition {
511 label: label.clone(),
512 action_type: *action_type,
513 shape: *shape,
514 color: *color,
515 highlight: *highlight,
516 },
517 _ => {
518 return Err(invalid(
519 "Canvas list or tag definition has the wrong type",
520 ));
521 }
522 };
523 definitions.insert(
524 *id,
525 Definition {
526 kind,
527 format: definition.format.clone(),
528 },
529 );
530 }
531 if let Some(id) = base_style {
532 let definition = revision
533 .nodes
534 .get(&id)
535 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
536 let Kind::Style { name } = &definition.kind else {
537 return Err(invalid("Canvas paragraph style has the wrong type"));
538 };
539 definitions.entry(id).or_insert_with(|| Definition {
540 kind: Kind::Style { name: name.clone() },
541 format: definition.format.clone(),
542 });
543 }
544 paragraphs.push(PageParagraph {
545 id,
546 parent,
547 level,
548 style: base_style,
549 format: format.clone(),
550 content,
551 lists: lists.clone(),
552 tags: node.tags.clone(),
553 collapsed: *collapse_state == Some(1),
554 });
555 pending.extend(
556 node.children
557 .iter()
558 .rev()
559 .map(|child| (*child, Some(id), next_level, format.clone())),
560 );
561 }
562 Kind::OutlineGroup => pending.extend(
563 node.children
564 .iter()
565 .rev()
566 .map(|child| (*child, parent, next_level, node.format.inherit(&inherited))),
567 ),
568 _ => unsupported.push(Unsupported {
569 id,
570 jcid: node.jcid,
571 layout: node.layout.clone(),
572 }),
573 }
574 }
575 Ok((paragraphs, unsupported))
576}
577
578fn read_table(
579 revision: &Revision<'_>,
580 id: ExGuid,
581 inherited: &Format,
582 definitions: &mut BTreeMap<ExGuid, Definition>,
583 seen: &mut BTreeSet<ExGuid>,
584 depth: usize,
585) -> Result<Table, onestore::Error> {
586 let invalid = |message| onestore::Error { offset: 0, message };
587 if depth > 64 {
588 return Err(invalid("Tables are nested too deeply"));
589 }
590 let node = &revision.nodes[&id];
591 let Kind::Table {
592 rows,
593 columns,
594 widths,
595 locked,
596 borders,
597 } = &node.kind
598 else {
599 unreachable!()
600 };
601 if rows.map(|n| n as usize) != Some(node.children.len())
602 || node.children.is_empty()
603 || columns.map(|n| n as usize) != Some(widths.len())
604 || widths.is_empty()
605 || (!locked.is_empty() && locked.len() != widths.len())
606 || widths.iter().any(|w| !w.is_finite() || *w < 36.0)
607 {
608 return Err(invalid("Table dimensions are inconsistent"));
609 }
610 let mut table_rows = Vec::new();
611 for row_id in &node.children {
612 if !seen.insert(*row_id) {
613 return Err(invalid("Repeated canvas table row"));
614 }
615 let row = revision
616 .nodes
617 .get(row_id)
618 .ok_or_else(|| invalid("Missing canvas table row"))?;
619 if !matches!(row.kind, Kind::Row) || row.children.len() != widths.len() {
620 return Err(invalid("Table row dimensions are inconsistent"));
621 }
622 let mut cells = Vec::new();
623 for cell_id in &row.children {
624 if !seen.insert(*cell_id) {
625 return Err(invalid("Repeated canvas table cell"));
626 }
627 let cell = revision
628 .nodes
629 .get(cell_id)
630 .ok_or_else(|| invalid("Missing canvas table cell"))?;
631 if cell.children.is_empty() {
632 return Err(invalid("A table cell has no paragraphs"));
633 }
634 let Kind::Cell { shading, indents } = &cell.kind else {
635 return Err(invalid("A table row contains an invalid cell"));
636 };
637 let (paragraphs, unsupported) = read_paragraphs(
638 revision,
639 cell,
640 &cell.format.inherit(inherited),
641 definitions,
642 seen,
643 depth,
644 )?;
645 cells.push(TableCell {
646 id: *cell_id,
647 layout: cell.layout.clone(),
648 indents: indents.clone(),
649 shading: *shading,
650 paragraphs,
651 unsupported,
652 });
653 }
654 table_rows.push(TableRow { id: *row_id, cells });
655 }
656 Ok(Table {
657 id,
658 columns: widths
659 .iter()
660 .enumerate()
661 .map(|(index, &width)| TableColumn {
662 width,
663 locked: locked.get(index).copied().unwrap_or(false),
664 })
665 .collect(),
666 rows: table_rows,
667 borders: *borders,
668 layout: node.layout.clone(),
669 tags: node.tags.clone(),
670 })
671}
672
673fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> {
674 revision
675 .roots
676 .get(&2)
677 .and_then(|id| revision.nodes.get(id))
678 .and_then(|node| {
679 if let Kind::Metadata { title, .. } = &node.kind {
680 title.as_deref()
681 } else {
682 None
683 }
684 })
685 .or_else(|| {
686 if let Kind::Page {
687 alternate_title, ..
688 } = &revision.nodes.get(&id)?.kind
689 {
690 alternate_title.as_deref()
691 } else {
692 None
693 }
694 })
695}
696
697impl PageParagraph {
698 pub fn text(&self) -> Option<&TextObject> {
699 match &self.content {
700 ParagraphContent::Text(text) => Some(text),
701 _ => None,
702 }
703 }
704 pub fn text_mut(&mut self) -> Option<&mut TextObject> {
705 match &mut self.content {
706 ParagraphContent::Text(text) => Some(text),
707 _ => None,
708 }
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use onestore::document::{Element, TextRun};
716
717 fn id(n: u32) -> ExGuid {
718 ExGuid {
719 n,
720 ..ExGuid::default()
721 }
722 }
723
724 fn element(kind: Kind<'_>) -> Element<'_> {
725 Element {
726 jcid: 0,
727 children: Vec::new(),
728 content: Vec::new(),
729 structure: Vec::new(),
730 spaces: Vec::new(),
731 child_level: None,
732 layout: Layout::default(),
733 format: Format::default(),
734 created: None,
735 modified: None,
736 original_author: None,
737 latest_author: None,
738 media_ids: Vec::new(),
739 media_time_ms: None,
740 tags: Vec::new(),
741 kind,
742 extra: Vec::new(),
743 }
744 }
745
746 fn revision() -> Revision<'static> {
747 let mut page = element(Kind::Page {
748 alternate_title: Some("Fallback".into()),
749 level: None,
750 width: None,
751 height: None,
752 margin_origin_x: Some(36.0),
753 margin_origin_y: Some(12.0),
754 rtl: None,
755 });
756 page.children.push(id(2));
757 let mut outline = element(Kind::Outline {
758 indents: vec![18.0, 0.0, 36.0],
759 });
760 outline.children.push(id(3));
761 outline.child_level = Some(1);
762 outline.format.font_size = Some(9.0);
763 outline.format.bold = Some(true);
764 let mut paragraph = element(Kind::Paragraph {
765 lists: Vec::new(),
766 paragraph_style: Some(id(5)),
767 collapse_state: Some(1),
768 });
769 paragraph.content.push(id(4));
770 paragraph.format.bold = Some(false);
771 let text = element(Kind::RichText {
772 text: "ab".into(),
773 runs: vec![TextRun {
774 start: 0,
775 end: 2,
776 format: None,
777 extra_set: None,
778 }],
779 paragraph_style: None,
780 boilerplate: false,
781 });
782 let mut style = element(Kind::Style {
783 name: Some("Body".into()),
784 });
785 style.format.font_size = Some(12.0);
786 let metadata = element(Kind::Metadata {
787 title: Some("Page title".into()),
788 level: None,
789 });
790 Revision {
791 roots: BTreeMap::from([(2, id(6))]),
792 nodes: BTreeMap::from([
793 (id(1), page),
794 (id(2), outline),
795 (id(3), paragraph),
796 (id(4), text),
797 (id(5), style),
798 (id(6), metadata),
799 ]),
800 }
801 }
802
803 fn table_revision() -> Revision<'static> {
804 let mut source = revision();
805 source.nodes.get_mut(&id(3)).unwrap().content = vec![id(7)];
806 let mut table = element(Kind::Table {
807 rows: Some(1),
808 columns: Some(2),
809 widths: vec![37.11, 99.0],
810 locked: vec![false, true],
811 borders: Some(false),
812 });
813 table.children = vec![id(8)];
814 let mut row = element(Kind::Row);
815 row.children = vec![id(9), id(10)];
816 source.nodes.insert(id(7), table);
817 source.nodes.insert(id(8), row);
818 for (cell, paragraph, text) in [(9, 11, 4), (10, 12, 13)] {
819 let mut node = element(Kind::Cell {
820 shading: Some(0x00ffff),
821 indents: vec![18.0, 0.0, 27.0, 27.0],
822 });
823 node.child_level = Some(1);
824 node.layout.max_width = Some(268.8);
825 node.children = vec![id(paragraph)];
826 if cell == 10 {
827 node.format.font_size = Some(13.0);
828 }
829 source.nodes.insert(id(cell), node);
830 let mut node = element(Kind::Paragraph {
831 lists: Vec::new(),
832 paragraph_style: None,
833 collapse_state: None,
834 });
835 node.content = vec![id(text)];
836 source.nodes.insert(id(paragraph), node);
837 }
838 source.nodes.insert(
839 id(13),
840 element(Kind::RichText {
841 text: "cd".into(),
842 runs: vec![TextRun {
843 start: 0,
844 end: 2,
845 format: None,
846 extra_set: None,
847 }],
848 paragraph_style: None,
849 boilerplate: false,
850 }),
851 );
852 source
853 }
854
855 #[test]
856 fn table_import_owns_cells_and_inherits_format_across_containers() {
857 let page = {
858 let source = table_revision();
859 let before = serde_json::to_vec(&source).unwrap();
860 let page = Page::from_revision(&source, id(1)).unwrap();
861 assert_eq!(serde_json::to_vec(&source).unwrap(), before);
862 page
863 };
864 let PageObject::Outline(outline) = &page.objects[0] else {
865 panic!()
866 };
867 let ParagraphContent::Table(table) = &outline.paragraphs[0].content else {
868 panic!()
869 };
870 assert_eq!(table.id, id(7));
871 assert_eq!(table.borders, Some(false));
872 assert_eq!(
873 table.columns,
874 [
875 TableColumn {
876 width: 37.11,
877 locked: false
878 },
879 TableColumn {
880 width: 99.0,
881 locked: true
882 }
883 ]
884 );
885 assert_eq!(table.rows[0].id, id(8));
886 for (index, cell) in table.rows[0].cells.iter().enumerate() {
887 assert_eq!(cell.id, id(9 + index as u32));
888 assert_eq!(cell.layout.max_width, Some(268.8));
889 assert_eq!(cell.indents, [18.0, 0.0, 27.0, 27.0]);
890 assert_eq!(cell.shading, Some(0x00ffff));
891 let paragraph = &cell.paragraphs[0];
892 assert_eq!(paragraph.parent, None);
893 assert_eq!(paragraph.level, 1);
894 assert_eq!(paragraph.id, id(11 + index as u32));
895 let text = paragraph.text().unwrap();
896 assert_eq!(text.id, if index == 0 { id(4) } else { id(13) });
897 assert_eq!(text.text.text(), if index == 0 { "ab" } else { "cd" });
898 assert_eq!(
899 text.text.spans()[0].format.font_size,
900 Some(if index == 0 { 12.0 } else { 13.0 })
901 );
902 assert_eq!(text.text.spans()[0].format.bold, Some(false));
903 }
904 }
905
906 #[test]
907 fn absent_column_locks_use_the_specified_unlocked_default() {
908 let mut source = table_revision();
909 let Kind::Table { locked, .. } = &mut source.nodes.get_mut(&id(7)).unwrap().kind else {
910 panic!()
911 };
912 locked.clear();
913 let page = Page::from_revision(&source, id(1)).unwrap();
914 let PageObject::Outline(outline) = &page.objects[0] else {
915 panic!()
916 };
917 let ParagraphContent::Table(table) = &outline.paragraphs[0].content else {
918 panic!()
919 };
920 assert!(table.columns.iter().all(|column| !column.locked));
921 }
922
923 #[test]
924 fn table_import_rejects_aliases_cycles_and_inconsistent_dimensions() {
925 for case in 0..7 {
926 let mut source = table_revision();
927 match case {
928 0 => source.nodes.get_mut(&id(8)).unwrap().children[1] = id(9),
929 1 => source.nodes.get_mut(&id(11)).unwrap().content = vec![id(7)],
930 2 => source.nodes.get_mut(&id(12)).unwrap().content = vec![id(4)],
931 3 => source.nodes.get_mut(&id(3)).unwrap().content.push(id(4)),
932 4 => source.nodes.get_mut(&id(9)).unwrap().children.clear(),
933 5 => {
934 let Kind::Table { columns, .. } =
935 &mut source.nodes.get_mut(&id(7)).unwrap().kind
936 else {
937 panic!()
938 };
939 *columns = Some(3);
940 }
941 _ => {
942 let Kind::Table { widths, .. } =
943 &mut source.nodes.get_mut(&id(7)).unwrap().kind
944 else {
945 panic!()
946 };
947 widths[0] = f32::INFINITY;
948 }
949 }
950 assert!(Page::from_revision(&source, id(1)).is_err(), "case {case}");
951 }
952 }
953
954 #[test]
955 fn nested_tables_import_with_a_bounded_depth() {
956 for levels in [1, 62, 65] {
957 let mut source = table_revision();
958 let mut paragraph = id(11);
959 for depth in 0..levels {
960 let base = 20 + depth * 4;
961 source.nodes.get_mut(&paragraph).unwrap().content = vec![id(base)];
962 let mut table = element(Kind::Table {
963 rows: Some(1),
964 columns: Some(1),
965 widths: vec![72.0],
966 locked: vec![true],
967 borders: Some(true),
968 });
969 table.children = vec![id(base + 1)];
970 let mut row = element(Kind::Row);
971 row.children = vec![id(base + 2)];
972 let mut cell = element(Kind::Cell {
973 shading: None,
974 indents: vec![18.0, 0.0, 27.0, 27.0],
975 });
976 cell.child_level = Some(1);
977 cell.children = vec![id(base + 3)];
978 let mut child = element(Kind::Paragraph {
979 lists: Vec::new(),
980 paragraph_style: None,
981 collapse_state: None,
982 });
983 child.content = vec![id(4)];
984 source.nodes.extend([
985 (id(base), table),
986 (id(base + 1), row),
987 (id(base + 2), cell),
988 (id(base + 3), child),
989 ]);
990 paragraph = id(base + 3);
991 }
992 assert_eq!(Page::from_revision(&source, id(1)).is_ok(), levels < 64);
993 }
994 }
995
996 #[test]
997 #[ignore = "requires CANVAS_TEST_SECTION pointing to the native Tab table capture"]
998 fn native_table_import() {
999 use onestore::{RevisionIndex, Store};
1000 let page = {
1001 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
1002 let store = Store::parse(&bytes).unwrap();
1003 let index = RevisionIndex::parse(&store).unwrap();
1004 let document = Document::parse(&index).unwrap();
1005 for (space, page) in document.pages().unwrap() {
1006 let space = &document.spaces[&space];
1007 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
1008 Page::from_revision(revision, page).unwrap();
1009 }
1010 Page::from_document(&document, "rows").unwrap()
1011 };
1012 let table = page
1013 .objects
1014 .iter()
1015 .find_map(|object| match object {
1016 PageObject::Outline(outline) => {
1017 outline.paragraphs.iter().find_map(|p| match &p.content {
1018 ParagraphContent::Table(table) => Some(table),
1019 _ => None,
1020 })
1021 }
1022 _ => None,
1023 })
1024 .unwrap();
1025 assert_eq!(table.rows.len(), 3);
1026 assert_eq!(table.columns.len(), 2);
1027 assert!(table.columns.iter().all(|c| !c.locked));
1028 let cells: Vec<_> = table.rows.iter().flat_map(|row| &row.cells).collect();
1029 let text: Vec<_> = cells
1030 .iter()
1031 .map(|cell| cell.paragraphs[0].text().unwrap().text.text())
1032 .collect();
1033 assert_eq!(text, ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", ""]);
1034 assert!(cells.iter().all(|c| c.indents == [18.0, 0.0, 27.0, 27.0]));
1035 use crate::document::{TextDocument, TextPosition};
1036 let outline = page
1037 .objects
1038 .iter()
1039 .find_map(|object| match object {
1040 PageObject::Outline(outline)
1041 if outline
1042 .paragraphs
1043 .iter()
1044 .any(|p| matches!(p.content, ParagraphContent::Table(_))) =>
1045 {
1046 Some(outline)
1047 }
1048 _ => None,
1049 })
1050 .unwrap();
1051 let mut document = TextDocument::from_nodes(outline.paragraphs.clone()).unwrap();
1052 let original = document.clone();
1053 for (index, cell) in cells.iter().enumerate() {
1054 let position = TextPosition {
1055 paragraph: index,
1056 offset: 0,
1057 };
1058 let edit = document
1059 .replace(
1060 position..position,
1061 vec![
1062 Paragraph::new("🧊".into(), Format::default()),
1063 Paragraph::new("text".into(), Format::default()),
1064 ],
1065 )
1066 .unwrap();
1067 assert_eq!(edit.container, Some(cell.id));
1068 let undo = document.apply(edit).unwrap();
1069 assert_eq!(document.paragraphs().nth(index).unwrap().text(), "🧊");
1070 document.apply(undo).unwrap();
1071 assert_eq!(document, original);
1072 }
1073 }
1074
1075 #[test]
1076 fn owns_page_content_and_resolves_style_before_ancestor_defaults() {
1077 let page = {
1078 let mut source = revision();
1079 source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag {
1080 definition: Some(id(7)),
1081 action_type: None,
1082 status: 0,
1083 created: Some(123),
1084 completed: None,
1085 start: None,
1086 due: None,
1087 task_id: None,
1088 extra_set: 0,
1089 });
1090 source.nodes.insert(
1091 id(7),
1092 element(Kind::TagDefinition {
1093 label: Some("To Do".into()),
1094 action_type: Some(0),
1095 shape: Some(3),
1096 color: None,
1097 highlight: None,
1098 }),
1099 );
1100 let before = serde_json::to_vec(&source).unwrap();
1101 let page = Page::from_revision(&source, id(1)).unwrap();
1102 assert_eq!(before, serde_json::to_vec(&source).unwrap());
1103 page
1104 };
1105 assert_eq!(page.title, "Page title");
1106 assert_eq!(page.margin_origin, [36.0, 12.0]);
1107 let PageObject::Outline(outline) = &page.objects[0] else {
1108 panic!()
1109 };
1110 assert_eq!(outline.paragraphs.len(), 1);
1111 let paragraph = &outline.paragraphs[0];
1112 assert!(paragraph.collapsed);
1113 assert_eq!(paragraph.level, 1);
1114 assert_eq!(paragraph.parent, None);
1115 assert_eq!(paragraph.style, Some(id(5)));
1116 assert!(matches!(&page.definitions[&id(5)].kind,
1117 Kind::Style { name: Some(name) } if name == "Body"));
1118 assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0));
1119 let text = &paragraph.text().unwrap().text;
1120 assert_eq!(text.text(), "ab");
1121 assert_eq!(text.spans()[0].format.font_size, Some(12.0));
1122 assert_eq!(text.spans()[0].format.bold, Some(false));
1123 assert_eq!(paragraph.text().unwrap().tags[0].created, Some(123));
1124 assert!(matches!(&page.definitions[&id(7)].kind,
1125 Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do"));
1126 }
1127
1128 #[test]
1129 fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() {
1130 use onestore::document::{Field, FieldValue};
1131 let mut source = revision();
1132 source.nodes.get_mut(&id(1)).unwrap().created = Some(7);
1133 let bytes = 134_333_468_649_123_456_u64.to_le_bytes();
1134 source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field {
1135 id: 0x18001c65,
1136 value: FieldValue::Bytes(&bytes),
1137 }]];
1138 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1139 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1140 let mut title = element(Kind::Title);
1141 title.children.push(id(2));
1142 source.nodes.insert(id(7), title);
1143 source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![Field {
1144 id: 0x88001cb5,
1145 value: FieldValue::NoData,
1146 }]];
1147 source.nodes.get_mut(&id(4)).unwrap().extra = vec![vec![Field {
1148 id: 0x88001c87,
1149 value: FieldValue::NoData,
1150 }]];
1151 let page = Page::from_revision(&source, id(1)).unwrap();
1152 assert_eq!(page.created, Some(134_333_468_649_123_456));
1153 let PageObject::Title(title) = &page.objects[0] else {
1154 panic!()
1155 };
1156 assert_eq!(title.date, Some(id(2)));
1157 assert_eq!(
1158 title.outlines[0].paragraphs[0].text().unwrap().date_field,
1159 Some(DateField::Time)
1160 );
1161 source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]);
1162 assert!(Page::from_revision(&source, id(1)).is_err());
1163 source.nodes.get_mut(&id(6)).unwrap().extra.clear();
1164 assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None);
1165 }
1166
1167 #[test]
1168 fn title_role_and_minimum_width_come_from_root_properties() {
1169 use onestore::document::{Field, FieldValue};
1170 let mut source = revision();
1171 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1172 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1173 let mut title = element(Kind::Title);
1174 title.children.push(id(2));
1175 source.nodes.insert(id(7), title);
1176 source.nodes.get_mut(&id(2)).unwrap().extra = vec![vec![
1177 Field {
1178 id: 0x88001cb4,
1179 value: FieldValue::NoData,
1180 },
1181 Field {
1182 id: 0x14001cec,
1183 value: FieldValue::Bytes(&[0, 0, 0x90, 0x40]),
1184 },
1185 ]];
1186 let page = Page::from_revision(&source, id(1)).unwrap();
1187 let PageObject::Title(title) = &page.objects[0] else {
1188 panic!()
1189 };
1190 assert!(title.outlines[0].title);
1191 assert_eq!(title.outlines[0].min_width, Some(162.0));
1192 source.nodes.get_mut(&id(2)).unwrap().extra[0][1].value =
1193 FieldValue::Bytes(&[0, 0, 0x80, 0x7f]);
1194 assert!(Page::from_revision(&source, id(1)).is_err());
1195 source.nodes.get_mut(&id(2)).unwrap().extra = vec![
1196 vec![Field {
1197 id: 0x08001cb4,
1198 value: FieldValue::NoData,
1199 }],
1200 vec![Field {
1201 id: 0x88001cb4,
1202 value: FieldValue::NoData,
1203 }],
1204 ];
1205 let page = Page::from_revision(&source, id(1)).unwrap();
1206 let PageObject::Title(title) = &page.objects[0] else {
1207 panic!()
1208 };
1209 assert!(!title.outlines[0].title);
1210 assert_eq!(title.outlines[0].min_width, None);
1211 }
1212
1213 #[test]
1214 fn preserves_paint_order_nested_parents_and_owned_image_payloads() {
1215 let bytes = vec![1, 2, 3, 4];
1216 let mut source = revision();
1217 let image = element(Kind::Image {
1218 container: Some(id(8)),
1219 filename: None,
1220 alt: Some("Image".into()),
1221 picture_width: None,
1222 picture_height: None,
1223 background: Some(true),
1224 printout: None,
1225 link: None,
1226 });
1227 let file = element(Kind::File {
1228 reference: onestore::FileDataReference::Internal([0; 16]),
1229 extension: "png".into(),
1230 payload: Some(bytes.as_slice()),
1231 });
1232 let mut title = element(Kind::Title);
1233 title.layout.x = Some(12.0);
1234 title.layout.y = Some(24.0);
1235 title.children.push(id(10));
1236 let title_outline = element(Kind::Outline {
1237 indents: Vec::new(),
1238 });
1239 let mut group = element(Kind::OutlineGroup);
1240 group.children.push(id(12));
1241 group.child_level = Some(1);
1242 let mut child = element(Kind::Paragraph {
1243 lists: Vec::new(),
1244 paragraph_style: None,
1245 collapse_state: None,
1246 });
1247 child.content.push(id(13));
1248 source.nodes.insert(
1249 id(13),
1250 element(Kind::RichText {
1251 text: "".into(),
1252 runs: Vec::new(),
1253 paragraph_style: None,
1254 boilerplate: false,
1255 }),
1256 );
1257 source.nodes.extend([
1258 (id(7), image),
1259 (id(8), file),
1260 (id(9), title),
1261 (id(10), title_outline),
1262 (id(11), group),
1263 (id(12), child),
1264 ]);
1265 source
1266 .nodes
1267 .get_mut(&id(1))
1268 .unwrap()
1269 .children
1270 .insert(0, id(7));
1271 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(9));
1272 source.nodes.get_mut(&id(3)).unwrap().children.push(id(11));
1273 source.nodes.get_mut(&id(3)).unwrap().child_level = Some(1);
1274 let page = Page::from_revision(&source, id(1)).unwrap();
1275 drop(source);
1276 drop(bytes);
1277 assert_eq!(page.objects.len(), 3);
1278 let PageObject::Image(image) = &page.objects[0] else {
1279 panic!()
1280 };
1281 assert_eq!(image.bytes.as_deref(), Some([1, 2, 3, 4].as_slice()));
1282 let PageObject::Outline(outline) = &page.objects[1] else {
1283 panic!()
1284 };
1285 assert_eq!(outline.paragraphs[1].id, id(12));
1286 assert_eq!(outline.paragraphs[1].parent, Some(id(3)));
1287 assert_eq!(outline.paragraphs[1].level, 3);
1288 let PageObject::Title(title) = &page.objects[2] else {
1289 panic!()
1290 };
1291 assert_eq!(title.id, id(9));
1292 assert_eq!([title.layout.x, title.layout.y], [Some(12.0), Some(24.0)]);
1293 assert_eq!(title.outlines[0].id, id(10));
1294 assert_eq!(title.outlines[0].layout.x, None);
1295 }
1296
1297 #[test]
1298 fn rejects_title_children_that_are_not_outlines() {
1299 let mut source = revision();
1300 let mut title = element(Kind::Title);
1301 title.children.push(id(8));
1302 source.nodes.insert(id(7), title);
1303 source.nodes.insert(id(8), element(Kind::Title));
1304 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1305 assert_eq!(
1306 Page::from_revision(&source, id(1)).err().unwrap().message,
1307 "Canvas title child is not an outline"
1308 );
1309 }
1310
1311 #[test]
1312 fn validates_empty_text_styles_missing_references_and_cycles() {
1313 let mut source = revision();
1314 source.nodes.get_mut(&id(4)).unwrap().kind = Kind::RichText {
1315 text: String::new(),
1316 runs: Vec::new(),
1317 paragraph_style: Some(id(5)),
1318 boilerplate: false,
1319 };
1320 source.nodes.get_mut(&id(3)).unwrap().format.font_size = Some(10.0);
1321 let page = Page::from_revision(&source, id(1)).unwrap();
1322 let PageObject::Outline(outline) = &page.objects[0] else {
1323 panic!()
1324 };
1325 assert_eq!(
1326 outline.paragraphs[0].text().unwrap().text.spans()[0]
1327 .format
1328 .font_size,
1329 Some(12.0)
1330 );
1331 source.nodes.get_mut(&id(3)).unwrap().children.push(id(2));
1332 assert!(Page::from_revision(&source, id(1)).is_err());
1333 source.nodes.get_mut(&id(3)).unwrap().children.clear();
1334 source.nodes.remove(&id(5));
1335 assert!(Page::from_revision(&source, id(1)).is_err());
1336 }
1337}
crates/canvas/src/text.rs deleted-566
...@@ -1,566 +0,0 @@
1use onestore::document::Format;
2use parley::Affinity;
3use std::{fmt, ops::Range};
4
5#[derive(Clone, Debug, PartialEq)]
6pub struct Span {
7 /// Exclusive UTF-8 boundary; the start is the preceding span's end.
8 pub end: usize,
9 pub format: Format,
10}
11
12#[derive(Clone, Debug, PartialEq)]
13/// Editable text styles are coalesced independently of serialized run boundaries.
14pub struct Paragraph {
15 text: String,
16 spans: Vec<Span>,
17}
18
19#[derive(Clone)]
20pub struct TextProjection {
21 text: Paragraph,
22 spans: Vec<ProjectedSpan>,
23 source_len: u32,
24}
25
26#[derive(Clone)]
27struct ProjectedSpan {
28 visible: Range<u32>,
29 source_start: u32,
30}
31
32impl TextProjection {
33 pub fn text(&self) -> &Paragraph {
34 &self.text
35 }
36
37 /// Maps visible UTF-8 boundaries to downstream source UTF-16 positions in one pass.
38 pub fn source_boundaries(&self) -> impl Iterator<Item = (usize, u32)> + '_ {
39 let mut spans = self.spans.iter().peekable();
40 let mut visible = 0;
41 self.text
42 .text
43 .char_indices()
44 .map(move |(byte, character)| {
45 while spans.peek().is_some_and(|span| span.visible.end <= visible) {
46 spans.next();
47 }
48 let span = spans.peek().unwrap();
49 let source = span.source_start + (visible - span.visible.start);
50 visible += character.len_utf16() as u32;
51 (byte, source)
52 })
53 .chain(std::iter::once((self.text.text.len(), self.source_len)))
54 }
55
56 /// Affinity selects the side of a hidden field at a visible boundary.
57 pub fn source_offset(&self, visible: u32, affinity: Affinity) -> Result<u32, EditError> {
58 self.text.byte_offset(visible)?;
59 Ok(match affinity {
60 Affinity::Downstream => self
61 .spans
62 .iter()
63 .find(|span| span.visible.end > visible)
64 .map(|span| span.source_start + (visible - span.visible.start))
65 .unwrap_or(self.source_len),
66 Affinity::Upstream => self
67 .spans
68 .iter()
69 .rev()
70 .find(|span| span.visible.start < visible)
71 .map(|span| span.source_start + (visible - span.visible.start))
72 .unwrap_or(0),
73 })
74 }
75
76 /// Hidden source positions collapse to their visible boundary.
77 pub fn visible_offset(&self, source: u32) -> Result<u32, EditError> {
78 if source > self.source_len {
79 return Err(EditError::InvalidRange);
80 }
81 for span in &self.spans {
82 if source < span.source_start {
83 return Ok(span.visible.start);
84 }
85 let length = span.visible.end - span.visible.start;
86 if source < span.source_start + length {
87 let visible = span.visible.start + source - span.source_start;
88 self.text.byte_offset(visible)?;
89 return Ok(visible);
90 }
91 }
92 self.text.utf16_offset(self.text.text.len())
93 }
94}
95
96#[derive(Clone, Debug, PartialEq)]
97pub struct Edit {
98 pub range: Range<u32>,
99 pub replacement: Paragraph,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub enum EditError {
104 InvalidRange,
105 TextTooLong,
106 InvalidStructure,
107 UnsupportedContent,
108 Identity(getrandom::Error),
109}
110
111impl fmt::Display for EditError {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 f.write_str(match self {
114 Self::Identity(error) => return error.fmt(f),
115 Self::UnsupportedContent => {
116 "This paragraph contains content the text editor cannot edit"
117 }
118 Self::InvalidRange => "Text range is outside the paragraph or splits a surrogate pair",
119 Self::TextTooLong => "Text exceeds the UTF-16 offset range",
120 Self::InvalidStructure => {
121 "This outline has duplicate objects or broken paragraph links"
122 }
123 })
124 }
125}
126
127impl std::error::Error for EditError {}
128
129pub(crate) fn new_id() -> Result<onestore::ExGuid, EditError> {
130 let mut guid = [0; 16];
131 getrandom::fill(&mut guid).map_err(EditError::Identity)?;
132 guid[7] = (guid[7] & 0x0f) | 0x40;
133 guid[8] = (guid[8] & 0x3f) | 0x80;
134 Ok(onestore::ExGuid { guid, n: 0 })
135}
136
137impl Paragraph {
138 pub fn project(&self) -> Result<TextProjection, EditError> {
139 let mut runs = Vec::new();
140 let mut spans: Vec<ProjectedSpan> = Vec::new();
141 let mut byte = 0;
142 let mut source = 0_u32;
143 let mut visible = 0_u32;
144 for span in &self.spans {
145 let fragment = &self.text[byte..span.end];
146 let length: u32 = fragment
147 .encode_utf16()
148 .count()
149 .try_into()
150 .map_err(|_| EditError::TextTooLong)?;
151 let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?;
152 if span.format.hidden != Some(true) {
153 runs.push((fragment.replace('\u{000b}', "\n"), span.format.clone()));
154 if length > 0 {
155 if let Some(last) = spans.last_mut()
156 && last.source_start + (last.visible.end - last.visible.start) == source
157 {
158 last.visible.end += length;
159 } else {
160 spans.push(ProjectedSpan {
161 visible: visible..visible + length,
162 source_start: source,
163 });
164 }
165 visible += length;
166 }
167 }
168 byte = span.end;
169 source = source_end;
170 }
171 if runs.is_empty() {
172 let mut format = self.spans[0].format.clone();
173 format.hidden = Some(false);
174 runs.push((String::new(), format));
175 }
176 Ok(TextProjection {
177 text: Self::from_runs(runs),
178 spans,
179 source_len: source,
180 })
181 }
182
183 pub fn new(text: String, format: Format) -> Self {
184 let end = text.len();
185 Self {
186 text,
187 spans: vec![Span { end, format }],
188 }
189 }
190
191 pub fn from_runs(runs: impl IntoIterator<Item = (String, Format)>) -> Self {
192 let mut text = String::new();
193 let mut spans: Vec<Span> = Vec::new();
194 for (fragment, format) in runs {
195 text.push_str(&fragment);
196 if let Some(last) = spans.last_mut() {
197 if last.format == format {
198 last.end = text.len();
199 continue;
200 }
201 if fragment.is_empty() && !text.is_empty() {
202 continue;
203 }
204 if last.end == 0 {
205 spans.clear();
206 }
207 }
208 spans.push(Span {
209 end: text.len(),
210 format,
211 });
212 }
213 if spans.is_empty() {
214 spans.push(Span {
215 end: 0,
216 format: Format::default(),
217 });
218 }
219 Self { text, spans }
220 }
221
222 pub fn text(&self) -> &str {
223 &self.text
224 }
225
226 pub fn spans(&self) -> &[Span] {
227 &self.spans
228 }
229
230 pub fn byte_offset(&self, utf16: u32) -> Result<usize, EditError> {
231 let mut units = 0_u64;
232 for (byte, character) in self.text.char_indices() {
233 if units == u64::from(utf16) {
234 return Ok(byte);
235 }
236 units += character.len_utf16() as u64;
237 if units > u64::from(utf16) {
238 return Err(EditError::InvalidRange);
239 }
240 }
241 if units == u64::from(utf16) {
242 Ok(self.text.len())
243 } else {
244 Err(EditError::InvalidRange)
245 }
246 }
247
248 pub fn utf16_offset(&self, byte: usize) -> Result<u32, EditError> {
249 self.text
250 .get(..byte)
251 .ok_or(EditError::InvalidRange)?
252 .encode_utf16()
253 .count()
254 .try_into()
255 .map_err(|_| EditError::TextTooLong)
256 }
257
258 pub fn format_at(&self, utf16: u32) -> Result<&Format, EditError> {
259 let byte = self.byte_offset(utf16)?;
260 let index = self.spans.partition_point(|span| span.end < byte);
261 Ok(&self.spans[index].format)
262 }
263
264 pub fn slice(&self, range: Range<u32>) -> Result<Self, EditError> {
265 if range.start > range.end {
266 return Err(EditError::InvalidRange);
267 }
268 let start = self.byte_offset(range.start)?;
269 let end = self.byte_offset(range.end)?;
270 if start == end {
271 return Ok(Self::new(
272 String::new(),
273 self.format_at(range.start)?.clone(),
274 ));
275 }
276 let mut previous = 0;
277 let mut spans = Vec::new();
278 for span in &self.spans {
279 if previous < end && span.end > start {
280 spans.push(Span {
281 end: span.end.min(end) - start,
282 format: span.format.clone(),
283 });
284 }
285 previous = span.end;
286 }
287 Ok(Self {
288 text: self.text[start..end].to_owned(),
289 spans,
290 })
291 }
292
293 /// Returns the inverse edit; applying that inverse returns a redo operation.
294 pub fn apply(&mut self, edit: Edit) -> Result<Edit, EditError> {
295 let end = self.utf16_offset(self.text.len())?;
296 let removed = self.slice(edit.range.clone())?;
297 let inserted = edit.replacement.utf16_offset(edit.replacement.text.len())?;
298 let new_end = edit
299 .range
300 .start
301 .checked_add(inserted)
302 .ok_or(EditError::TextTooLong)?;
303 let resulting_len = end
304 .checked_sub(edit.range.end - edit.range.start)
305 .and_then(|n| n.checked_add(inserted))
306 .ok_or(EditError::TextTooLong)?;
307 if resulting_len == 0 {
308 *self = edit.replacement;
309 return Ok(Edit {
310 range: edit.range.start..new_end,
311 replacement: removed,
312 });
313 }
314 let prefix = self.slice(0..edit.range.start)?;
315 let suffix = self.slice(edit.range.end..end)?;
316 let mut runs = Vec::new();
317 for part in [prefix, edit.replacement, suffix] {
318 let mut start = 0;
319 for span in part.spans {
320 if span.end > start {
321 runs.push((part.text[start..span.end].to_owned(), span.format));
322 }
323 start = span.end;
324 }
325 }
326 *self = Self::from_runs(runs);
327 Ok(Edit {
328 range: edit.range.start..new_end,
329 replacement: removed,
330 })
331 }
332
333 pub fn append(&mut self, other: Self) -> Result<Edit, EditError> {
334 let end = self.utf16_offset(self.text.len())?;
335 self.apply(Edit {
336 range: end..end,
337 replacement: other,
338 })
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn regular() -> Format {
347 Format {
348 font: Some("Arial".into()),
349 font_size: Some(11.0),
350 ..Format::default()
351 }
352 }
353
354 fn bold() -> Format {
355 Format {
356 bold: Some(true),
357 ..regular()
358 }
359 }
360
361 #[test]
362 fn soft_breaks_keep_source_offsets_and_formatting() {
363 let source = Paragraph::from_runs([("A\u{000b}".into(), regular()), ("🌳".into(), bold())]);
364 let projection = source.project().unwrap();
365 assert_eq!(source.text(), "A\u{000b}🌳");
366 assert_eq!(projection.text().text(), "A\n🌳");
367 assert_eq!(projection.text().spans(), source.spans());
368 assert_eq!(
369 projection.source_boundaries().collect::<Vec<_>>(),
370 [(0, 0), (1, 1), (2, 2), (6, 4)]
371 );
372 for offset in [0, 1, 2, 4] {
373 assert_eq!(projection.visible_offset(offset), Ok(offset));
374 assert_eq!(
375 projection.source_offset(offset, Affinity::Downstream),
376 Ok(offset)
377 );
378 }
379 assert!(projection.visible_offset(3).is_err());
380 }
381
382 #[test]
383 fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() {
384 let hidden = Format {
385 hidden: Some(true),
386 ..regular()
387 };
388 let source = Paragraph::from_runs([
389 ("A".into(), regular()),
390 ("🌳".into(), hidden.clone()),
391 ("e\u{301}".into(), bold()),
392 ("X".into(), hidden),
393 ]);
394 let original = source.clone();
395 let projection = source.project().unwrap();
396 assert_eq!(projection.text().text(), "Ae\u{301}");
397 assert_eq!(
398 projection.source_boundaries().collect::<Vec<_>>(),
399 [(0, 0), (1, 3), (2, 4), (4, 6)]
400 );
401 for (visible, upstream, downstream) in [(0, 0, 0), (1, 1, 3), (2, 4, 4), (3, 5, 6)] {
402 assert_eq!(
403 projection.source_offset(visible, Affinity::Upstream),
404 Ok(upstream)
405 );
406 assert_eq!(
407 projection.source_offset(visible, Affinity::Downstream),
408 Ok(downstream)
409 );
410 }
411 for (source, visible) in [(0, 0), (1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3)] {
412 assert_eq!(projection.visible_offset(source), Ok(visible));
413 }
414 assert_eq!(source, original);
415 }
416
417 #[test]
418 fn projection_validates_visible_surrogates_and_preserves_empty_field_boundaries() {
419 let visible = Paragraph::new("a🌳z".into(), regular()).project().unwrap();
420 assert_eq!(
421 visible.source_offset(2, Affinity::Downstream),
422 Err(EditError::InvalidRange)
423 );
424 assert_eq!(visible.visible_offset(2), Err(EditError::InvalidRange));
425 let hidden = Paragraph::new(
426 "🌳".into(),
427 Format {
428 hidden: Some(true),
429 ..regular()
430 },
431 )
432 .project()
433 .unwrap();
434 assert!(hidden.text().text().is_empty());
435 assert_eq!(hidden.source_boundaries().collect::<Vec<_>>(), [(0, 2)]);
436 assert_eq!(
437 visible.source_boundaries().collect::<Vec<_>>(),
438 [(0, 0), (1, 1), (5, 3), (6, 4)]
439 );
440 assert_eq!(hidden.source_offset(0, Affinity::Upstream), Ok(0));
441 assert_eq!(hidden.source_offset(0, Affinity::Downstream), Ok(2));
442 assert_eq!(hidden.visible_offset(1), Ok(0));
443 assert_eq!(hidden.visible_offset(3), Err(EditError::InvalidRange));
444 }
445
446 #[test]
447 fn offsets_distinguish_bytes_utf16_and_scalars() {
448 let text = Paragraph::new("a🌳e\u{301}".into(), regular());
449 for (utf16, byte) in [(0, 0), (1, 1), (3, 5), (4, 6), (5, 8)] {
450 assert_eq!(text.byte_offset(utf16), Ok(byte));
451 assert_eq!(text.utf16_offset(byte), Ok(utf16));
452 }
453 assert_eq!(text.byte_offset(2), Err(EditError::InvalidRange));
454 assert_eq!(text.utf16_offset(2), Err(EditError::InvalidRange));
455 assert_eq!(text.byte_offset(6), Err(EditError::InvalidRange));
456 assert_eq!(text.utf16_offset(9), Err(EditError::InvalidRange));
457 }
458
459 #[test]
460 fn replacement_preserves_styles_outside_selection() {
461 let mut text = Paragraph::from_runs([
462 ("plain ".into(), regular()),
463 ("bold".into(), bold()),
464 (" end".into(), regular()),
465 ]);
466 let original = text.clone();
467 let edit = Edit {
468 range: 4..8,
469 replacement: Paragraph::new("🌳".into(), bold()),
470 };
471 let undo = text.apply(edit).unwrap();
472 assert_eq!(text.text(), "plai🌳ld end");
473 assert_eq!(
474 text.spans()
475 .iter()
476 .map(|s| (s.end, s.format.bold))
477 .collect::<Vec<_>>(),
478 [(4, None), (10, Some(true)), (14, None)]
479 );
480 let after = text.clone();
481 let redo = text.apply(undo).unwrap();
482 assert_eq!(text, original);
483 text.apply(redo).unwrap();
484 assert_eq!(text, after);
485 }
486
487 #[test]
488 fn invalid_edits_leave_text_and_styles_unchanged() {
489 let original = Paragraph::new("a🌳b".into(), regular());
490 for (start, end) in [(2, 3), (0, 2), (3, 2), (0, 5)] {
491 let mut text = original.clone();
492 assert_eq!(
493 text.apply(Edit {
494 range: start..end,
495 replacement: Paragraph::new("x".into(), bold())
496 }),
497 Err(EditError::InvalidRange)
498 );
499 assert_eq!(text, original);
500 }
501 }
502
503 #[test]
504 fn undo_restores_empty_paragraph_format() {
505 let mut text = Paragraph::new(String::new(), regular());
506 let original = text.clone();
507 let undo = text
508 .apply(Edit {
509 range: 0..0,
510 replacement: Paragraph::new("bold".into(), bold()),
511 })
512 .unwrap();
513 text.apply(undo).unwrap();
514 assert_eq!(text, original);
515 }
516
517 #[test]
518 fn every_scalar_range_round_trips_styled_unicode_edits() {
519 let original = Paragraph::from_runs([
520 ("a🌳".into(), regular()),
521 ("e\u{301}Χ©ΧœΧ•Χ".into(), bold()),
522 ("Z".into(), regular()),
523 ]);
524 let boundaries: Vec<_> = original
525 .text()
526 .char_indices()
527 .map(|(n, _)| n)
528 .chain([original.text().len()])
529 .collect();
530 for &start in &boundaries {
531 for &end in boundaries.iter().filter(|&&end| end >= start) {
532 let range =
533 original.utf16_offset(start).unwrap()..original.utf16_offset(end).unwrap();
534 for replacement in ["", "πŸ‘©‍πŸ‘©‍πŸ‘§‍πŸ‘¦", "xyz", "\u{301}"] {
535 let mut edited = original.clone();
536 let undo = edited
537 .apply(Edit {
538 range: range.clone(),
539 replacement: Paragraph::new(replacement.into(), bold()),
540 })
541 .unwrap();
542 assert_eq!(
543 edited.text(),
544 format!(
545 "{}{replacement}{}",
546 &original.text()[..start],
547 &original.text()[end..]
548 )
549 );
550 let after = edited.clone();
551 let redo = edited.apply(undo).unwrap();
552 assert_eq!(edited, original);
553 edited.apply(redo).unwrap();
554 assert_eq!(edited, after);
555 }
556 }
557 let at = original.utf16_offset(start).unwrap();
558 let mut left = original.slice(0..at).unwrap();
559 let right = original
560 .slice(at..original.utf16_offset(original.text.len()).unwrap())
561 .unwrap();
562 left.append(right).unwrap();
563 assert_eq!(left, original);
564 }
565 }
566}
crates/snowbound/src/accessibility.rs+7-6
...@@ -7,8 +7,8 @@ use canvas::{...@@ -7,8 +7,8 @@ use canvas::{
7 date::DateField,7 date::DateField,
8 document::TextPosition,8 document::TextPosition,
9 editor::{CanvasEditor, Selection, TextOutline},9 editor::{CanvasEditor, Selection, TextOutline},
10 text::EditError,
11};10};
11use onestore::page::text::EditError;
12use parley::Affinity;12use parley::Affinity;
13use std::{collections::HashMap, ops::Range};13use std::{collections::HashMap, ops::Range};
1414
...@@ -519,7 +519,7 @@ impl Accessibility {...@@ -519,7 +519,7 @@ impl Accessibility {
519 );519 );
520 position.offset = projection.source_offset(520 position.offset = projection.source_offset(
521 projection.text().utf16_offset(cursor.index())?,521 projection.text().utf16_offset(cursor.index())?,
522 Affinity::Downstream,522 onestore::page::text::Affinity::Downstream,
523 )?;523 )?;
524 let caret = layout.caret(cursor, 0.0);524 let caret = layout.caret(cursor, 0.0);
525 let mut positions = self525 let mut positions = self
...@@ -584,12 +584,13 @@ impl Accessibility {...@@ -584,12 +584,13 @@ impl Accessibility {
584#[cfg(test)]584#[cfg(test)]
585mod tests {585mod tests {
586 use super::*;586 use super::*;
587 use canvas::{document::TextDocument, layout::TextEngine, text::Paragraph};587 use canvas::{document::TextDocument, layout::TextEngine};
588 use onestore::document::Format;588 use onestore::document::Format;
589 use onestore::page::text::Paragraph;
589590
590 #[test]591 #[test]
591 fn title_flow_updates_read_only_hit_and_accessibility_bounds() {592 fn title_flow_updates_read_only_hit_and_accessibility_bounds() {
592 use canvas::page::{Page, PageObject, Title, Unsupported};593 use onestore::page::{Page, PageObject, Title, Unsupported};
593 let mut engine = TextEngine::default();594 let mut engine = TextEngine::default();
594 let mut title = TextOutline::new(595 let mut title = TextOutline::new(
595 &mut engine,596 &mut engine,
...@@ -697,7 +698,7 @@ mod tests {...@@ -697,7 +698,7 @@ mod tests {
697 #[test]698 #[test]
698 fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() {699 fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() {
699 use canvas::gpu::page::PageScene;700 use canvas::gpu::page::PageScene;
700 use canvas::page::{Page, PageObject, Unsupported};701 use onestore::page::{Page, PageObject, Unsupported};
701 let page = Page {702 let page = Page {
702 created: None,703 created: None,
703 title: String::new(),704 title: String::new(),
...@@ -780,7 +781,7 @@ mod tests {...@@ -780,7 +781,7 @@ mod tests {
780781
781 #[test]782 #[test]
782 fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() {783 fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() {
783 use canvas::page::{Definition, Outline};784 use onestore::page::{Definition, Outline};
784 use onestore::{785 use onestore::{
785 ExGuid,786 ExGuid,
786 document::{Kind, Layout, Tag},787 document::{Kind, Layout, Tag},
crates/snowbound/src/main.rs+7-7
...@@ -12,10 +12,10 @@ use canvas::{...@@ -12,10 +12,10 @@ use canvas::{
12 CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline,12 CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline,
13 },13 },
14 layout::TextEngine,14 layout::TextEngine,
15 page::Page,
16 text::Paragraph,
17};15};
18use onestore::document::Format;16use onestore::document::Format;
17use onestore::page::Page;
18use onestore::page::text::Paragraph;
19use std::{19use std::{
20 error::Error,20 error::Error,
21 path::PathBuf,21 path::PathBuf,
...@@ -1920,7 +1920,7 @@ mod tests {...@@ -1920,7 +1920,7 @@ mod tests {
1920 created: Some(1),1920 created: Some(1),
1921 margin_origin: [36.0, 14.4],1921 margin_origin: [36.0, 14.4],
1922 definitions: Default::default(),1922 definitions: Default::default(),
1923 objects: vec![canvas::page::PageObject::Title(canvas::page::Title {1923 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
1924 id: onestore::ExGuid::default(),1924 id: onestore::ExGuid::default(),
1925 date: Some(fields[1].id),1925 date: Some(fields[1].id),
1926 layout: Default::default(),1926 layout: Default::default(),
...@@ -2018,12 +2018,12 @@ mod tests {...@@ -2018,12 +2018,12 @@ mod tests {
2018 None2018 None
2019 );2019 );
2020 }2020 }
2021 let page = canvas::page::Page {2021 let page = onestore::page::Page {
2022 title: String::new(),2022 title: String::new(),
2023 created: None,2023 created: None,
2024 margin_origin: [0.0; 2],2024 margin_origin: [0.0; 2],
2025 definitions: Default::default(),2025 definitions: Default::default(),
2026 objects: vec![canvas::page::PageObject::Outline(source)],2026 objects: vec![onestore::page::PageObject::Outline(source)],
2027 };2027 };
2028 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();2028 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2029 let offset = [50.0, 300.0];2029 let offset = [50.0, 300.0];
...@@ -2713,7 +2713,7 @@ mod tests {...@@ -2713,7 +2713,7 @@ mod tests {
27132713
2714 #[test]2714 #[test]
2715 fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {2715 fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
2716 use canvas::page::{Outline, PageObject, Unsupported};2716 use onestore::page::{Outline, PageObject, Unsupported};
2717 for readonly_on_top in [false, true] {2717 for readonly_on_top in [false, true] {
2718 let mut engine = TextEngine::default();2718 let mut engine = TextEngine::default();
2719 let document = TextDocument::new(vec![Paragraph::new(2719 let document = TextDocument::new(vec![Paragraph::new(
...@@ -2924,7 +2924,7 @@ mod tests {...@@ -2924,7 +2924,7 @@ mod tests {
29242924
2925 #[test]2925 #[test]
2926 fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {2926 fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
2927 use canvas::page::{PageObject, Unsupported};2927 use onestore::page::{PageObject, Unsupported};
2928 let mut engine = TextEngine::default();2928 let mut engine = TextEngine::default();
2929 let mut editor = CanvasEditor::new(2929 let mut editor = CanvasEditor::new(
2930 &mut engine,2930 &mut engine,
fuzz/Cargo.lock-1
...@@ -81,7 +81,6 @@ dependencies = [...@@ -81,7 +81,6 @@ dependencies = [
81name = "canvas"81name = "canvas"
82version = "0.1.0"82version = "0.1.0"
83dependencies = [83dependencies = [
84 "getrandom",
85 "onestore",84 "onestore",
86 "parley",85 "parley",
87 "serde",86 "serde",
fuzz/fuzz_targets/canvas_editor.rs+6-11
...@@ -1,11 +1,6 @@...@@ -1,11 +1,6 @@
1#![no_main]1#![no_main]
2use canvas::{2use canvas::{date::PageDate, document::{TextDocument, TextPosition}, editor::{CanvasEditor, Movement, SelectionUnit, TextOutline}, layout::TextEngine};
3 date::PageDate,3use onestore::page::text::{Paragraph};
4 document::{TextDocument, TextPosition},
5 editor::{CanvasEditor, Movement, SelectionUnit, TextOutline},
6 layout::TextEngine,
7 text::Paragraph,
8};
9use libfuzzer_sys::fuzz_target;4use libfuzzer_sys::fuzz_target;
10use onestore::document::Format;5use onestore::document::Format;
11use std::{cell::RefCell, collections::BTreeMap};6use std::{cell::RefCell, collections::BTreeMap};
...@@ -85,7 +80,7 @@ fuzz_target!(|input: &[u8]| {...@@ -85,7 +80,7 @@ fuzz_target!(|input: &[u8]| {
85 assert!(!matches!(80 assert!(!matches!(
86 error,81 error,
87 canvas::editor::EditorError::Edit(82 canvas::editor::EditorError::Edit(
88 canvas::text::EditError::InvalidStructure83 onestore::page::text::EditError::InvalidStructure
89 )84 )
90 ));85 ));
91 }),86 }),
...@@ -199,7 +194,7 @@ fuzz_target!(|input: &[u8]| {...@@ -199,7 +194,7 @@ fuzz_target!(|input: &[u8]| {
199 if let Some(date) = &date {194 if let Some(date) = &date {
200 fields.push(date.source().clone());195 fields.push(date.source().clone());
201 }196 }
202 let mut objects = vec![canvas::page::PageObject::Title(canvas::page::Title {197 let mut objects = vec![onestore::page::PageObject::Title(onestore::page::Title {
203 id: onestore::ExGuid::default(),198 id: onestore::ExGuid::default(),
204 layout: Default::default(),199 layout: Default::default(),
205 date: date_id,200 date: date_id,
...@@ -213,7 +208,7 @@ fuzz_target!(|input: &[u8]| {...@@ -213,7 +208,7 @@ fuzz_target!(|input: &[u8]| {
213 .into_iter()208 .into_iter()
214 .enumerate()209 .enumerate()
215 {210 {
216 objects.push(canvas::page::PageObject::Image(canvas::page::Image {211 objects.push(onestore::page::PageObject::Image(onestore::page::Image {
217 id: onestore::ExGuid {212 id: onestore::ExGuid {
218 n: index.try_into().unwrap(),213 n: index.try_into().unwrap(),
219 ..Default::default()214 ..Default::default()
...@@ -231,7 +226,7 @@ fuzz_target!(|input: &[u8]| {...@@ -231,7 +226,7 @@ fuzz_target!(|input: &[u8]| {
231 }));226 }));
232 }227 }
233 CanvasEditor::from_page(228 CanvasEditor::from_page(
234 canvas::page::Page {229 onestore::page::Page {
235 title: String::new(),230 title: String::new(),
236 created: date.map(|date| date.timestamp()),231 created: date.map(|date| date.timestamp()),
237 margin_origin: [0.0; 2],232 margin_origin: [0.0; 2],