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"
394394version = "0.1.0"
395395dependencies = [
396396 "bytemuck",
397 "getrandom 0.4.3",
398397 "image",
399398 "onestore",
400399 "parley",
crates/canvas/Cargo.toml-1
......@@ -13,7 +13,6 @@ parley.workspace = true
1313serde = { version = "1.0.229", features = ["derive"] }
1414serde_json = "1.0.151"
1515skrifa = "0.44.0"
16getrandom = "0.4.3"
1716
1817bytemuck = { version = "1.25", features = ["derive"], optional = true }
1918swash = { version = "0.2.10", optional = true }
crates/canvas/examples/render_page.rs+2-1
......@@ -1,5 +1,6 @@
11use canvas::gpu::{Renderer, Viewport, page::PageScene};
2use canvas::{layout::TextEngine, page::Page};
2use canvas::layout::TextEngine;
3use onestore::page::Page;
34use onestore::{RevisionIndex, Store, document::Document};
45use std::{env, fs, sync::Arc, time::Duration};
56
crates/canvas/src/bin/layout_probe.rs+2-1
......@@ -1,5 +1,6 @@
1use canvas::{layout::TextEngine, text::Paragraph};
1use canvas::layout::TextEngine;
22use onestore::document::Format;
3use onestore::page::text::Paragraph;
34use serde::{Deserialize, Serialize};
45use serde_json::json;
56use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
crates/canvas/src/bin/page_probe.rs+2-2
......@@ -1,9 +1,9 @@
11use canvas::{
22 document::TextDocument,
33 layout::TextEngine,
4 outline::OutlineLayout,
5 page::{Outline, Page, PageObject},
4 outline::{Arrange, OutlineLayout},
65};
6use onestore::page::{Outline, Page, PageObject};
77use onestore::{RevisionIndex, Store, document::Document};
88use serde_json::json;
99use std::{env, fs, io, sync::Arc, time::Instant};
crates/canvas/src/date.rs+7-13
......@@ -2,18 +2,14 @@ use crate::{
22 document::TextDocument,
33 editor::EditorError,
44 layout::TextEngine,
5 outline::{OutlineLayout, TITLE_WIDTH},
6 page::{Definition, Outline, PageParagraph},
7 text::EditError,
5 outline::{OutlineLayout, TITLE_WIDTH, outline_layout},
86};
97use onestore::ExGuid;
8use onestore::page::text::EditError;
9use onestore::page::{Definition, Outline, PageParagraph};
1010use std::collections::BTreeMap;
1111
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum DateField {
14 Date = 0,
15 Time = 1,
16}
12pub use onestore::page::DateField;
1713
1814#[derive(Clone)]
1915pub struct PageDate {
......@@ -85,7 +81,7 @@ impl PageDate {
8581 {
8682 return Err(EditError::UnsupportedContent.into());
8783 }
88 let layout = source.layout_with_width(engine, definitions, TITLE_WIDTH)?;
84 let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?;
8985 let date = Self {
9086 timestamp,
9187 source,
......@@ -104,10 +100,8 @@ impl PageDate {
104100#[cfg(test)]
105101mod tests {
106102 use super::*;
107 use crate::{
108 editor::{CanvasEditor, TextOutline},
109 text::Paragraph,
110 };
103 use crate::editor::{CanvasEditor, TextOutline};
104 use onestore::page::text::Paragraph;
111105
112106 #[test]
113107 fn date_and_text_edits_share_history_without_changing_identities_or_selection() {
crates/canvas/src/document.rs+88-8
......@@ -1,7 +1,5 @@
1use crate::{
2 page::{PageParagraph, ParagraphContent, TextObject},
3 text::{EditError, Paragraph, new_id},
4};
1use onestore::page::text::{EditError, Paragraph, new_id};
2use onestore::page::{PageParagraph, ParagraphContent, TextObject};
53use onestore::{ExGuid, document::Format};
64use std::{
75 collections::{BTreeMap, BTreeSet},
......@@ -36,7 +34,7 @@ pub(crate) fn node(text: Paragraph, format: Format) -> Result<PageParagraph, Edi
3634 parent: None,
3735 level: 1,
3836 format,
39 content: crate::page::ParagraphContent::Text(TextObject {
37 content: onestore::page::ParagraphContent::Text(TextObject {
4038 date_field: None,
4139 id: new_id()?,
4240 text,
......@@ -435,8 +433,8 @@ impl TextDocument {
435433#[cfg(test)]
436434mod tests {
437435 use super::*;
438 use crate::page::{Table, TableCell, TableColumn, TableRow};
439436 use onestore::document::Format;
437 use onestore::page::{Table, TableCell, TableColumn, TableRow};
440438
441439 fn position(paragraph: usize, offset: u32) -> TextPosition {
442440 TextPosition { paragraph, offset }
......@@ -781,7 +779,7 @@ mod tests {
781779 #[test]
782780 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
783781 fn imported_nodes_preserve_identity_through_edit_and_undo() {
784 use crate::page::{Page, PageObject};
782 use onestore::page::{Page, PageObject};
785783 use onestore::{RevisionIndex, Store, document::Document};
786784 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
787785 let page = {
......@@ -939,7 +937,7 @@ mod tests {
939937 assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure));
940938 let mut unsupported = original.nodes().to_vec();
941939 unsupported[0].content =
942 crate::page::ParagraphContent::Unsupported(crate::page::Unsupported {
940 onestore::page::ParagraphContent::Unsupported(onestore::page::Unsupported {
943941 id: new_id().unwrap(),
944942 jcid: 0x60012,
945943 layout: Default::default(),
......@@ -1270,4 +1268,86 @@ mod tests {
12701268 }
12711269 assert!(TextDocument::new(Vec::new()).is_err());
12721270 }
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 }
12731353}
crates/canvas/src/editor.rs+50-44
......@@ -2,11 +2,14 @@ use crate::{
22 date::PageDate,
33 document::{DocumentEdit, TextDocument, TextPosition, edited_nodes, leaves},
44 layout::{LayoutError, TextEngine},
5 outline::{OutlineLayout, ParagraphLayout, arrange, indentation, visible_paragraphs},
6 page::{Definition, Outline, PageParagraph},
7 text::{EditError, Paragraph},
5 outline::{
6 Arrange, OutlineLayout, ParagraphLayout, arrange, indentation, outline_layout,
7 visible_paragraphs,
8 },
89};
910use onestore::ExGuid;
11use onestore::page::text::{EditError, Paragraph};
12use onestore::page::{Definition, Outline, PageParagraph};
1013use parley::{
1114 Affinity, BoundingBox,
1215 editing::{Cursor, Selection as ParagraphSelection},
......@@ -180,7 +183,7 @@ impl TextOutline {
180183 },
181184 )?;
182185 Ok(Self {
183 id: crate::text::new_id()?,
186 id: onestore::page::text::new_id()?,
184187 title: false,
185188 min_width: None,
186189 layout: onestore::document::Layout {
......@@ -227,7 +230,8 @@ impl TextOutline {
227230 {
228231 return Err(LayoutError::InvalidSpacing.into());
229232 }
230 outline.layout_with_width(
233 outline_layout(
234 outline,
231235 engine,
232236 definitions,
233237 outline
......@@ -399,14 +403,14 @@ impl TextOutline {
399403 continue;
400404 };
401405 match &node.content {
402 crate::page::ParagraphContent::Text(_) => {
406 onestore::page::ParagraphContent::Text(_) => {
403407 result.push(
404408 *paragraphs
405409 .get(&node.id)
406410 .ok_or(EditError::InvalidStructure)?,
407411 );
408412 }
409 crate::page::ParagraphContent::Table(table) => {
413 onestore::page::ParagraphContent::Table(table) => {
410414 let layout = tables.get(&table.id).ok_or(EditError::InvalidStructure)?;
411415 let cells = layout
412416 .cells
......@@ -441,7 +445,7 @@ impl TextOutline {
441445 pending.push(visible_paragraphs(cell.paragraphs.iter()));
442446 }
443447 }
444 crate::page::ParagraphContent::Unsupported(_) => {
448 onestore::page::ParagraphContent::Unsupported(_) => {
445449 return Err(EditError::UnsupportedContent);
446450 }
447451 }
......@@ -475,14 +479,14 @@ impl TextOutline {
475479 let mut target = None;
476480 for node in visible_paragraphs(nodes.iter()) {
477481 let top = match &node.content {
478 crate::page::ParagraphContent::Text(_) => {
482 onestore::page::ParagraphContent::Text(_) => {
479483 paragraphs
480484 .get(&node.id)
481485 .ok_or(EditError::InvalidStructure)?
482486 .1
483487 .origin[1]
484488 }
485 crate::page::ParagraphContent::Table(table) => {
489 onestore::page::ParagraphContent::Table(table) => {
486490 tables
487491 .get(&table.id)
488492 .and_then(|layout| layout.cells.first())
......@@ -490,7 +494,7 @@ impl TextOutline {
490494 .rect[1]
491495 - 1.86
492496 }
493 crate::page::ParagraphContent::Unsupported(_) => {
497 onestore::page::ParagraphContent::Unsupported(_) => {
494498 return Err(EditError::UnsupportedContent);
495499 }
496500 };
......@@ -502,7 +506,7 @@ impl TextOutline {
502506 }
503507 }
504508 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 {
506510 return paragraphs
507511 .get(&target.id)
508512 .map(|(index, _)| *index)
......@@ -648,7 +652,7 @@ impl CanvasEditor {
648652 }
649653
650654 pub fn from_page(
651 mut page: crate::page::Page,
655 mut page: onestore::page::Page,
652656 engine: &mut TextEngine,
653657 ) -> Result<Self, EditorError> {
654658 let (objects, mut outlines, date) = page::build(&mut page, engine, true)?;
......@@ -818,7 +822,7 @@ impl CanvasEditor {
818822 ))
819823 }
820824 page::Content::ReadOnly(object)
821 if !matches!(object.source, crate::page::PageObject::Title(_)) =>
825 if !matches!(object.source, onestore::page::PageObject::Title(_)) =>
822826 {
823827 Some((object.source.id(), object.source.layout(), object.rect()))
824828 }
......@@ -1329,7 +1333,7 @@ impl CanvasEditor {
13291333 paragraph: self.active_outline().source_index(index),
13301334 offset: paragraph
13311335 .projection
1332 .source_offset(visible, cursor.affinity())?,
1336 .source_offset(visible, crate::affinity(cursor.affinity()))?,
13331337 };
13341338 selection.affinities[slot] = cursor.affinity();
13351339 }
......@@ -1579,7 +1583,7 @@ impl CanvasEditor {
15791583 } else if outline.origin()[0] >= 180.0 && trailing >= 2 {
15801584 let node = &outline.document.nodes()[last.saturating_sub(trailing)];
15811585 let bottom = match &node.content {
1582 crate::page::ParagraphContent::Text(_) => {
1586 onestore::page::ParagraphContent::Text(_) => {
15831587 let paragraph = outline
15841588 .shaped
15851589 .paragraphs
......@@ -1588,7 +1592,7 @@ impl CanvasEditor {
15881592 .ok_or(EditError::InvalidStructure)?;
15891593 f64::from(paragraph.origin[1]) + f64::from(paragraph.text.height())
15901594 }
1591 crate::page::ParagraphContent::Table(table) => {
1595 onestore::page::ParagraphContent::Table(table) => {
15921596 let cell = outline
15931597 .shaped
15941598 .tables
......@@ -1598,7 +1602,7 @@ impl CanvasEditor {
15981602 .ok_or(EditError::InvalidStructure)?;
15991603 f64::from(cell.rect[3]) + 1.68
16001604 }
1601 crate::page::ParagraphContent::Unsupported(_) => {
1605 onestore::page::ParagraphContent::Unsupported(_) => {
16021606 return Err(EditError::UnsupportedContent.into());
16031607 }
16041608 } + f64::from(outline.origin()[1]);
......@@ -1773,7 +1777,7 @@ impl CanvasEditor {
17731777 paragraph: self.active_outline().source_index(index),
17741778 offset: paragraph
17751779 .projection
1776 .source_offset(visible, next.affinity())?,
1780 .source_offset(visible, crate::affinity(next.affinity()))?,
17771781 };
17781782 if !extend {
17791783 self.active_outline_mut().selection.positions[0] = position;
......@@ -1984,12 +1988,14 @@ impl CanvasEditor {
19841988 {
19851989 let visible = paragraph.projection.text();
19861990 let bytes = cluster.text_range();
1987 range.start.offset = paragraph
1988 .projection
1989 .source_offset(visible.utf16_offset(bytes.start)?, Affinity::Downstream)?;
1990 range.end.offset = paragraph
1991 .projection
1992 .source_offset(visible.utf16_offset(bytes.end)?, Affinity::Upstream)?;
1991 range.start.offset = paragraph.projection.source_offset(
1992 visible.utf16_offset(bytes.start)?,
1993 onestore::page::text::Affinity::Downstream,
1994 )?;
1995 range.end.offset = paragraph.projection.source_offset(
1996 visible.utf16_offset(bytes.end)?,
1997 onestore::page::text::Affinity::Upstream,
1998 )?;
19931999 } else {
19942000 let document = &self.active_outline().document;
19952001 let Some(neighbor) = (if backward {
......@@ -2690,7 +2696,7 @@ impl CanvasEditor {
26902696 .nodes()
26912697 .iter()
26922698 .chain(&edit.replacement)
2693 .any(|node| matches!(node.content, crate::page::ParagraphContent::Table(_)))
2699 .any(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_)))
26942700 {
26952701 let sources = outline
26962702 .document
......@@ -2891,10 +2897,8 @@ mod tests {
28912897 use onestore::document::Format;
28922898
28932899 fn table_editor(engine: &mut TextEngine) -> CanvasEditor {
2894 use crate::{
2895 page::{ParagraphContent, Table, TableCell, TableColumn, TableRow},
2896 text::new_id,
2897 };
2900 use onestore::page::text::new_id;
2901 use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
28982902 let mut editor = CanvasEditor::new(
28992903 engine,
29002904 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
......@@ -2975,7 +2979,8 @@ mod tests {
29752979 fn structured_construction_and_resize_preserve_cells_and_enclose_locked_columns() {
29762980 let mut engine = TextEngine::default();
29772981 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 {
29792984 panic!()
29802985 };
29812986 table.rows[0].cells[0].paragraphs[0]
......@@ -3064,7 +3069,7 @@ mod tests {
30643069
30653070 #[test]
30663071 fn nested_table_extents_remain_reachable_beyond_the_parent_columns() {
3067 use crate::page::ParagraphContent;
3072 use onestore::page::ParagraphContent;
30683073 let mut engine = TextEngine::default();
30693074 let mut source = table_editor(&mut engine).active_outline().snapshot();
30703075 let mut nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();
......@@ -3215,7 +3220,8 @@ mod tests {
32153220
32163221 #[test]
32173222 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;
32193225 let mut engine = TextEngine::default();
32203226 let nested = table_editor(&mut engine).active_outline().document.nodes()[0].clone();
32213227 let mut editor = table_editor(&mut engine);
......@@ -3403,7 +3409,7 @@ mod tests {
34033409
34043410 #[test]
34053411 fn overflowing_cell_caret_moves_to_the_next_rows_visual_column() {
3406 use crate::page::ParagraphContent;
3412 use onestore::page::ParagraphContent;
34073413 let mut engine = TextEngine::default();
34083414 let mut source = table_editor(&mut engine).active_outline().snapshot();
34093415 let ParagraphContent::Table(table) = &mut source.paragraphs[0].content else {
......@@ -3804,7 +3810,7 @@ mod tests {
38043810
38053811 #[test]
38063812 fn column_width_edits_reflow_and_restore_structured_drafts() {
3807 use crate::page::ParagraphContent;
3813 use onestore::page::ParagraphContent;
38083814 let mut engine = TextEngine::default();
38093815 for draft in [false, true] {
38103816 let mut editor = table_editor(&mut engine);
......@@ -4193,7 +4199,7 @@ mod tests {
41934199
41944200 #[test]
41954201 fn cross_outline_validation_includes_table_row_and_cell_ids() {
4196 use crate::page::ParagraphContent;
4202 use onestore::page::ParagraphContent;
41974203 let mut engine = TextEngine::default();
41984204 let first = table_editor(&mut engine).active_outline().clone();
41994205 let second = table_editor(&mut engine).active_outline().clone();
......@@ -4225,7 +4231,7 @@ mod tests {
42254231
42264232 #[test]
42274233 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};
42294235 let mut engine = TextEngine::default();
42304236 let mut title = TextOutline::new(
42314237 &mut engine,
......@@ -4254,7 +4260,7 @@ mod tests {
42544260 (120.0, 80.0, true),
42554261 ]
42564262 .map(|(x, y, background)| Image {
4257 id: crate::text::new_id().unwrap(),
4263 id: onestore::page::text::new_id().unwrap(),
42584264 layout: onestore::document::Layout {
42594265 x: Some(x),
42604266 y: Some(y),
......@@ -4272,7 +4278,7 @@ mod tests {
42724278 .map(|image| image.bytes.as_ref().unwrap().clone());
42734279 let mut objects = vec![
42744280 PageObject::Title(Title {
4275 id: crate::text::new_id().unwrap(),
4281 id: onestore::page::text::new_id().unwrap(),
42764282 date: None,
42774283 layout: Default::default(),
42784284 outlines: vec![title],
......@@ -5597,7 +5603,7 @@ mod tests {
55975603
55985604 #[test]
55995605 fn native_outline_omits_outer_paragraph_spacing_through_split_and_undo() {
5600 use crate::page::{Page, PageObject};
5606 use onestore::page::{Page, PageObject};
56015607 use onestore::{RevisionIndex, Store, document::Document};
56025608 let store = Store::parse(include_bytes!(
56035609 "../../../resources/canvas/baseline-anchors.one"
......@@ -5740,7 +5746,7 @@ mod tests {
57405746 let outline = Outline {
57415747 title: false,
57425748 min_width: None,
5743 id: crate::text::new_id().unwrap(),
5749 id: onestore::page::text::new_id().unwrap(),
57445750 layout: onestore::document::Layout {
57455751 max_width: Some(220.0),
57465752 reserved_width: Some(180.0),
......@@ -5812,7 +5818,7 @@ mod tests {
58125818 nodes[2].collapsed = true;
58135819 nodes[3].parent = Some(nodes[2].id);
58145820 nodes[3].level = 3;
5815 let marker = crate::text::new_id().unwrap();
5821 let marker = onestore::page::text::new_id().unwrap();
58165822 nodes[1].lists.push(marker);
58175823 let definitions = BTreeMap::from([(
58185824 marker,
......@@ -5832,7 +5838,7 @@ mod tests {
58325838 let outline = Outline {
58335839 title: false,
58345840 min_width: None,
5835 id: crate::text::new_id().unwrap(),
5841 id: onestore::page::text::new_id().unwrap(),
58365842 layout: onestore::document::Layout {
58375843 x: Some(30.0),
58385844 y: Some(45.0),
......@@ -5963,7 +5969,7 @@ mod tests {
59635969 #[test]
59645970 #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
59655971 fn imported_editor_reflows_and_restores_native_outline_geometry() {
5966 use crate::page::{Page, PageObject};
5972 use onestore::page::{Page, PageObject};
59675973 use onestore::{RevisionIndex, Store, document::Document};
59685974 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
59695975 let page = {
crates/canvas/src/editor/page.rs+4-4
......@@ -2,10 +2,10 @@ use super::{EditorError, TextOutline};
22use crate::{
33 date::PageDate,
44 layout::{LayoutError, TextEngine, TextLayout},
5 outline::OutlineLayout,
6 page::{Image, Outline, Page, PageObject},
7 text::Paragraph,
5 outline::{Arrange, OutlineLayout},
86};
7use onestore::page::text::Paragraph;
8use onestore::page::{Image, Outline, Page, PageObject};
99
1010pub(crate) enum Content {
1111 Date {
......@@ -246,7 +246,7 @@ pub(crate) fn build(
246246 continue;
247247 }
248248 Err(EditorError::Edit(
249 crate::text::EditError::UnsupportedContent,
249 onestore::page::text::EditError::UnsupportedContent,
250250 )) => {}
251251 Err(error) => return Err(error),
252252 }
crates/canvas/src/editor/table.rs+3-3
......@@ -1,6 +1,6 @@
11use super::*;
2use crate::page::{ParagraphContent, TableCell, TableColumn, TableRow};
3use crate::text::new_id;
2use onestore::page::text::new_id;
3use onestore::page::{ParagraphContent, TableCell, TableColumn, TableRow};
44
55struct CellLocation<'a> {
66 container: Option<ExGuid>,
......@@ -125,7 +125,7 @@ impl CanvasEditor {
125125 lists: Vec::new(),
126126 tags: Vec::new(),
127127 collapsed: false,
128 content: ParagraphContent::Table(crate::page::Table {
128 content: ParagraphContent::Table(onestore::page::Table {
129129 id: new_id()?,
130130 columns: vec![
131131 TableColumn {
crates/canvas/src/gpu/mod.rs+2-1
......@@ -1019,8 +1019,9 @@ pub fn colorref(color: u32) -> [f32; 4] {
10191019#[cfg(test)]
10201020mod tests {
10211021 use super::*;
1022 use crate::{layout::TextEngine, text::Paragraph};
1022 use crate::layout::TextEngine;
10231023 use onestore::document::Format as TextFormat;
1024 use onestore::page::text::Paragraph;
10241025 use std::time::Duration;
10251026
10261027 #[test]
crates/canvas/src/gpu/page.rs+20-21
......@@ -5,8 +5,8 @@ use crate::{
55 date::DateField,
66 editor::{CanvasEditor, EditorError},
77 layout::{LayoutError, TextEngine},
8 page::Page,
98};
9use onestore::page::Page;
1010use std::fmt;
1111
1212/// Retained drawing data in the source page's coordinate system.
......@@ -519,17 +519,16 @@ impl crate::outline::OutlineLayout {
519519#[cfg(test)]
520520mod tests {
521521 use super::*;
522 use crate::{
523 editor::TextOutline,
524 page::{Image, PageObject},
525 text::Paragraph,
526 };
522 use crate::editor::TextOutline;
523 use onestore::page::text::Paragraph;
524 use onestore::page::{Image, PageObject};
527525 use onestore::{ExGuid, document::Layout};
528526 use std::{collections::BTreeMap, sync::Arc};
529527
530528 #[test]
531529 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;
533532 let mut engine = TextEngine::default();
534533 let mut fields = ["Header", "September 8, 2026"].map(|text| {
535534 let document =
......@@ -740,7 +739,7 @@ mod tests {
740739 vec![outline],
741740 BTreeMap::from([(
742741 style,
743 crate::page::Definition {
742 onestore::page::Definition {
744743 kind: onestore::document::Kind::Style {
745744 name: Some("p".into()),
746745 },
......@@ -797,7 +796,8 @@ mod tests {
797796 #[test]
798797 #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"]
799798 fn native_title_flow() {
800 use crate::{document::TextDocument, page::Image};
799 use crate::document::TextDocument;
800 use onestore::page::Image;
801801 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
802802 let store = onestore::Store::parse(&bytes).unwrap();
803803 let index = onestore::RevisionIndex::parse(&store).unwrap();
......@@ -906,7 +906,7 @@ mod tests {
906906 (120.0, 60.0, false),
907907 ] {
908908 page.objects.push(PageObject::Image(Image {
909 id: crate::text::new_id().unwrap(),
909 id: onestore::page::text::new_id().unwrap(),
910910 bytes: Some(image_bytes.clone()),
911911 layout: Layout {
912912 x: Some(x),
......@@ -961,7 +961,8 @@ mod tests {
961961
962962 #[test]
963963 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;
965966 let mut engine = TextEngine::default();
966967 let fields =
967968 [("Header", 20.751953), ("Date\u{000b}Time", 12.207031)].map(|(text, line_spacing)| {
......@@ -1040,7 +1041,7 @@ mod tests {
10401041 let objects = [0.0, 20.0]
10411042 .into_iter()
10421043 .map(|position| {
1043 PageObject::Unsupported(crate::page::Unsupported {
1044 PageObject::Unsupported(onestore::page::Unsupported {
10441045 id: ExGuid {
10451046 guid: [position as u8; 16],
10461047 n: 1,
......@@ -1080,11 +1081,9 @@ mod tests {
10801081
10811082 #[test]
10821083 fn unsupported_tags_and_titles_leave_supported_outlines_editable() {
1083 use crate::{
1084 document::TextDocument,
1085 page::{Definition, Outline, Title},
1086 };
1084 use crate::document::TextDocument;
10871085 use onestore::document::{Kind, Tag};
1086 use onestore::page::{Definition, Outline, Title};
10881087 for title in [false, true] {
10891088 let definition = ExGuid {
10901089 guid: [9; 16],
......@@ -1190,10 +1189,8 @@ mod tests {
11901189
11911190 #[test]
11921191 fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() {
1193 use crate::{
1194 document::TextDocument,
1195 page::{Outline, Unsupported},
1196 };
1192 use crate::document::TextDocument;
1193 use onestore::page::{Outline, Unsupported};
11971194 let document = TextDocument::new(vec![Paragraph::new(
11981195 "preserved text".into(),
11991196 Default::default(),
......@@ -1347,7 +1344,9 @@ mod tests {
13471344
13481345 #[test]
13491346 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;
13511350 let outline = |x, text: &str| {
13521351 let document =
13531352 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap();
crates/canvas/src/gpu/profile.rs+3-1
......@@ -1,5 +1,7 @@
11use super::*;
2use crate::{layout::TextEngine, page::Page, text::Paragraph};
2use crate::layout::TextEngine;
3use onestore::page::Page;
4use onestore::page::text::Paragraph;
35use std::{
46 sync::Arc,
57 time::{Duration, Instant},
crates/canvas/src/layout.rs+1-1
......@@ -1,4 +1,4 @@
1use crate::text::Paragraph;
1use onestore::page::text::Paragraph;
22use parley::{
33 Affinity, Alignment, AlignmentOptions, BoundingBox, FontContext, FontFamily, FontStyle,
44 FontWeight, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty,
crates/canvas/src/lib.rs+8-2
......@@ -2,9 +2,15 @@ pub mod document;
22pub mod editor;
33pub mod layout;
44pub mod outline;
5pub mod page;
6pub mod text;
75
86pub mod date;
97#[cfg(feature = "gpu")]
108pub 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 @@
11use crate::{
22 document::{DocumentEdit, edited_nodes},
33 layout::{LayoutError, TextEngine, TextLayout},
4 page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title},
5 text::{Paragraph, TextProjection},
64};
5use onestore::page::text::{Paragraph, TextProjection};
6use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title};
77use onestore::{
88 ExGuid,
99 document::{Format, Kind},
......@@ -498,8 +498,20 @@ impl OutlineLayout {
498498 }
499499}
500500
501impl Outline {
502 pub fn layout(
501/// Shapes a page object into positioned paragraph layouts.
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(
503515 &self,
504516 engine: &mut TextEngine,
505517 definitions: &BTreeMap<ExGuid, Definition>,
......@@ -509,51 +521,53 @@ impl Outline {
509521 .reserved_width
510522 .or(self.layout.max_width)
511523 .ok_or(LayoutError::InvalidWidth)?;
512 self.layout_with_width(engine, definitions, width)
524 outline_layout(self, engine, definitions, width)
513525 }
526}
514527
515 pub(crate) fn layout_with_width(
516 &self,
517 engine: &mut TextEngine,
518 definitions: &BTreeMap<ExGuid, Definition>,
519 width: f32,
520 ) -> Result<OutlineLayout, LayoutError> {
521 if self.indents.len() < 2 || self.indents.iter().any(|v| !v.is_finite() || *v < 0.0) {
522 return Err(LayoutError::InvalidIndentation);
523 }
524 if !self.unsupported.is_empty() {
525 return Err(LayoutError::UnsupportedContent);
526 }
527 if self
528 .paragraphs
529 .iter()
530 .any(|node| matches!(node.content, ParagraphContent::Table(_)))
531 {
532 return OutlineLayout::flow(
533 self.paragraphs.iter(),
534 &self.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,
528pub(crate) fn outline_layout(
529 outline: &Outline,
530 engine: &mut TextEngine,
531 definitions: &BTreeMap<ExGuid, Definition>,
532 width: f32,
533) -> Result<OutlineLayout, LayoutError> {
534 if outline.indents.len() < 2 || outline.indents.iter().any(|v| !v.is_finite() || *v < 0.0) {
535 return Err(LayoutError::InvalidIndentation);
536 }
537 if !outline.unsupported.is_empty() {
538 return Err(LayoutError::UnsupportedContent);
539 }
540 if outline
541 .paragraphs
542 .iter()
543 .any(|node| matches!(node.content, ParagraphContent::Table(_)))
544 {
545 return OutlineLayout::flow(
546 outline.paragraphs.iter(),
547 &outline.indents,
549548 width,
550 self.layout.width_set_by_user == Some(true),
551 )
549 outline.layout.width_set_by_user == Some(true),
550 0,
551 None,
552 &mut |node, width, indents| {
553 ParagraphLayout::shape(engine, node, width, indents, definitions)
554 },
555 );
552556 }
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 )
553565}
554566
555impl Title {
556 pub fn layout(
567impl Arrange for Title {
568 type Output = Vec<([f32; 2], OutlineLayout)>;
569
570 fn layout(
557571 &self,
558572 engine: &mut TextEngine,
559573 definitions: &BTreeMap<ExGuid, Definition>,
......@@ -573,7 +587,7 @@ impl Title {
573587 .reserved_width
574588 .or(outline.layout.max_width)
575589 .unwrap_or(TITLE_WIDTH);
576 let layout = outline.layout_with_width(engine, definitions, width)?;
590 let layout = outline_layout(outline, engine, definitions, width)?;
577591 let origin = [
578592 outline.layout.x.unwrap_or(0.0),
579593 bottom + outline.layout.y.unwrap_or(0.0),
......@@ -595,8 +609,8 @@ impl Title {
595609#[cfg(test)]
596610mod tests {
597611 use super::*;
598 use crate::page::{PageParagraph, TextObject};
599612 use onestore::document::Layout;
613 use onestore::page::{PageParagraph, TextObject};
600614
601615 fn paragraph(n: u32, text: &str, level: u32, parent: Option<u32>) -> PageParagraph {
602616 PageParagraph {
......@@ -610,7 +624,7 @@ mod tests {
610624 }),
611625 level,
612626 format: Format::default(),
613 content: crate::page::ParagraphContent::Text(TextObject {
627 content: onestore::page::ParagraphContent::Text(TextObject {
614628 date_field: None,
615629 id: ExGuid {
616630 n: n + 100,
......@@ -627,7 +641,7 @@ mod tests {
627641 }
628642
629643 fn table(rows: &[&[&str]], widths: &[f32], mut n: u32) -> PageParagraph {
630 use crate::page::{TableCell, TableColumn, TableRow};
644 use onestore::page::{TableCell, TableColumn, TableRow};
631645 let mut id = || {
632646 n += 1;
633647 ExGuid {
......@@ -816,7 +830,7 @@ mod tests {
816830 #[test]
817831 #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"]
818832 fn native_table_layout() {
819 use crate::page::{Page, PageObject};
833 use onestore::page::{Page, PageObject};
820834 use onestore::{RevisionIndex, Store, document::Document};
821835 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
822836 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::{
77 date::DateField,
88 document::TextPosition,
99 editor::{CanvasEditor, Selection, TextOutline},
10 text::EditError,
1110};
11use onestore::page::text::EditError;
1212use parley::Affinity;
1313use std::{collections::HashMap, ops::Range};
1414
......@@ -519,7 +519,7 @@ impl Accessibility {
519519 );
520520 position.offset = projection.source_offset(
521521 projection.text().utf16_offset(cursor.index())?,
522 Affinity::Downstream,
522 onestore::page::text::Affinity::Downstream,
523523 )?;
524524 let caret = layout.caret(cursor, 0.0);
525525 let mut positions = self
......@@ -584,12 +584,13 @@ impl Accessibility {
584584#[cfg(test)]
585585mod tests {
586586 use super::*;
587 use canvas::{document::TextDocument, layout::TextEngine, text::Paragraph};
587 use canvas::{document::TextDocument, layout::TextEngine};
588588 use onestore::document::Format;
589 use onestore::page::text::Paragraph;
589590
590591 #[test]
591592 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};
593594 let mut engine = TextEngine::default();
594595 let mut title = TextOutline::new(
595596 &mut engine,
......@@ -697,7 +698,7 @@ mod tests {
697698 #[test]
698699 fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() {
699700 use canvas::gpu::page::PageScene;
700 use canvas::page::{Page, PageObject, Unsupported};
701 use onestore::page::{Page, PageObject, Unsupported};
701702 let page = Page {
702703 created: None,
703704 title: String::new(),
......@@ -780,7 +781,7 @@ mod tests {
780781
781782 #[test]
782783 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};
784785 use onestore::{
785786 ExGuid,
786787 document::{Kind, Layout, Tag},
crates/snowbound/src/main.rs+7-7
......@@ -12,10 +12,10 @@ use canvas::{
1212 CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline,
1313 },
1414 layout::TextEngine,
15 page::Page,
16 text::Paragraph,
1715};
1816use onestore::document::Format;
17use onestore::page::Page;
18use onestore::page::text::Paragraph;
1919use std::{
2020 error::Error,
2121 path::PathBuf,
......@@ -1920,7 +1920,7 @@ mod tests {
19201920 created: Some(1),
19211921 margin_origin: [36.0, 14.4],
19221922 definitions: Default::default(),
1923 objects: vec![canvas::page::PageObject::Title(canvas::page::Title {
1923 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
19241924 id: onestore::ExGuid::default(),
19251925 date: Some(fields[1].id),
19261926 layout: Default::default(),
......@@ -2018,12 +2018,12 @@ mod tests {
20182018 None
20192019 );
20202020 }
2021 let page = canvas::page::Page {
2021 let page = onestore::page::Page {
20222022 title: String::new(),
20232023 created: None,
20242024 margin_origin: [0.0; 2],
20252025 definitions: Default::default(),
2026 objects: vec![canvas::page::PageObject::Outline(source)],
2026 objects: vec![onestore::page::PageObject::Outline(source)],
20272027 };
20282028 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
20292029 let offset = [50.0, 300.0];
......@@ -2713,7 +2713,7 @@ mod tests {
27132713
27142714 #[test]
27152715 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};
27172717 for readonly_on_top in [false, true] {
27182718 let mut engine = TextEngine::default();
27192719 let document = TextDocument::new(vec![Paragraph::new(
......@@ -2924,7 +2924,7 @@ mod tests {
29242924
29252925 #[test]
29262926 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};
29282928 let mut engine = TextEngine::default();
29292929 let mut editor = CanvasEditor::new(
29302930 &mut engine,
fuzz/Cargo.lock-1
......@@ -81,7 +81,6 @@ dependencies = [
8181name = "canvas"
8282version = "0.1.0"
8383dependencies = [
84 "getrandom",
8584 "onestore",
8685 "parley",
8786 "serde",
fuzz/fuzz_targets/canvas_editor.rs+6-11
......@@ -1,11 +1,6 @@
11#![no_main]
2use canvas::{
3 date::PageDate,
4 document::{TextDocument, TextPosition},
5 editor::{CanvasEditor, Movement, SelectionUnit, TextOutline},
6 layout::TextEngine,
7 text::Paragraph,
8};
2use canvas::{date::PageDate, document::{TextDocument, TextPosition}, editor::{CanvasEditor, Movement, SelectionUnit, TextOutline}, layout::TextEngine};
3use onestore::page::text::{Paragraph};
94use libfuzzer_sys::fuzz_target;
105use onestore::document::Format;
116use std::{cell::RefCell, collections::BTreeMap};
......@@ -85,7 +80,7 @@ fuzz_target!(|input: &[u8]| {
8580 assert!(!matches!(
8681 error,
8782 canvas::editor::EditorError::Edit(
88 canvas::text::EditError::InvalidStructure
83 onestore::page::text::EditError::InvalidStructure
8984 )
9085 ));
9186 }),
......@@ -199,7 +194,7 @@ fuzz_target!(|input: &[u8]| {
199194 if let Some(date) = &date {
200195 fields.push(date.source().clone());
201196 }
202 let mut objects = vec![canvas::page::PageObject::Title(canvas::page::Title {
197 let mut objects = vec![onestore::page::PageObject::Title(onestore::page::Title {
203198 id: onestore::ExGuid::default(),
204199 layout: Default::default(),
205200 date: date_id,
......@@ -213,7 +208,7 @@ fuzz_target!(|input: &[u8]| {
213208 .into_iter()
214209 .enumerate()
215210 {
216 objects.push(canvas::page::PageObject::Image(canvas::page::Image {
211 objects.push(onestore::page::PageObject::Image(onestore::page::Image {
217212 id: onestore::ExGuid {
218213 n: index.try_into().unwrap(),
219214 ..Default::default()
......@@ -231,7 +226,7 @@ fuzz_target!(|input: &[u8]| {
231226 }));
232227 }
233228 CanvasEditor::from_page(
234 canvas::page::Page {
229 onestore::page::Page {
235230 title: String::new(),
236231 created: date.map(|date| date.timestamp()),
237232 margin_origin: [0.0; 2],