From ac8ebd9b6dfd7e1c002ec56155fc01c435562209 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 24 Sep 2026 10:50:33 -0700 Subject: [PATCH] feat: lay out pictures, files and handwriting inside outlines as OneNote does An outline holding a picture, file or handwriting paragraph opened as a read-only placeholder, the last one on "m: under the moon". Each now takes its own paragraph in the flow and stays part of the editable outline: text navigation passes over it, edits reflow around it, and text is never joined across it. Pictures draw at their user-set size, files as their stored icon centered over their name without extension in a 54 pt column, and handwriting from the paragraph's top-left. Each matches a OneNote 2010 capture of the picture, attachment and handwriting fixtures within a pixel or two, the rest being the substitute for Calibri. Assisted-by: claude-opus-5.5 --- crates/canvas/src/document.rs | 21 +++- crates/canvas/src/editor.rs | 145 +++++++++++++++++----- crates/canvas/src/gpu/page.rs | 108 ++++++++++++++--- crates/canvas/src/outline.rs | 220 ++++++++++++++++++++++++++++++++-- crates/snowbound/src/main.rs | 10 +- tools/canvas/README.md | 2 + 6 files changed, 450 insertions(+), 56 deletions(-) diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index c39ed873578593710123916b57b9be5c7f981884..7ad11b5db4d2fc61e8fd9135152056695e3904f3 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -154,12 +154,23 @@ pub(crate) fn validate_nodes( return Err(EditError::InvalidStructure); } } - ParagraphContent::Image(_) - | ParagraphContent::Attachment(_) - | ParagraphContent::Ink(_) - | ParagraphContent::Unsupported(_) => { - return Err(EditError::UnsupportedContent); + ParagraphContent::Image(image) => { + if !ids.insert(image.id) { + return Err(EditError::InvalidStructure); + } + crate::outline::image_size(image).ok_or(EditError::UnsupportedContent)?; } + ParagraphContent::Attachment(file) => { + if !ids.insert(file.id) { + return Err(EditError::InvalidStructure); + } + } + ParagraphContent::Ink(ink) => { + if !ids.insert(ink.id) { + return Err(EditError::InvalidStructure); + } + } + ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent), ParagraphContent::Table(table) => { if !ids.insert(table.id) || table.rows.is_empty() diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 3eae70374765d7dcdfad58dd81b5a62bccd3068c..70000323bd98a5c72751d6ef9319f49697dfc934 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -458,8 +458,8 @@ impl TextOutline { } onestore::page::ParagraphContent::Image(_) | onestore::page::ParagraphContent::Attachment(_) - | onestore::page::ParagraphContent::Ink(_) - | onestore::page::ParagraphContent::Unsupported(_) => { + | onestore::page::ParagraphContent::Ink(_) => {} + onestore::page::ParagraphContent::Unsupported(_) => { return Err(EditError::UnsupportedContent); } } @@ -508,10 +508,11 @@ impl TextOutline { .rect[1] - 1.86 } + // Pictures, files and ink hold no caret; text around them takes the hit. onestore::page::ParagraphContent::Image(_) | onestore::page::ParagraphContent::Attachment(_) - | onestore::page::ParagraphContent::Ink(_) - | onestore::page::ParagraphContent::Unsupported(_) => { + | onestore::page::ParagraphContent::Ink(_) => continue, + onestore::page::ParagraphContent::Unsupported(_) => { return Err(EditError::UnsupportedContent); } }; @@ -559,17 +560,6 @@ struct Composition { } impl ParagraphLayout { - fn reset_origin(&mut self, x: f32) { - let offset = x - self.origin[0]; - self.origin = [x, 0.0]; - for (_, origin) in &mut self.markers { - origin[0] += offset; - } - for tag in &mut self.tags { - tag.origin[0] += offset; - } - } - fn cursor(&self, source: u32, affinity: Affinity) -> Result { let visible = self.projection.visible_offset(source)?; let byte = self.projection.text().byte_offset(visible)?; @@ -1830,10 +1820,23 @@ impl CanvasEditor { .ok_or(EditError::InvalidStructure)?; f64::from(cell.rect[3]) + 1.68 } - onestore::page::ParagraphContent::Image(_) - | onestore::page::ParagraphContent::Attachment(_) - | onestore::page::ParagraphContent::Ink(_) - | onestore::page::ParagraphContent::Unsupported(_) => { + onestore::page::ParagraphContent::Image(onestore::page::Image { + id, + .. + }) + | onestore::page::ParagraphContent::Attachment( + onestore::page::Attachment { id, .. }, + ) + | onestore::page::ParagraphContent::Ink(onestore::page::Ink { + id, .. + }) => outline + .shaped + .objects + .iter() + .find(|object| object.id == *id) + .map(|object| f64::from(object.bottom)) + .ok_or(EditError::InvalidStructure)?, + onestore::page::ParagraphContent::Unsupported(_) => { return Err(EditError::UnsupportedContent.into()); } } + f64::from(outline.origin()[1]); @@ -2829,7 +2832,7 @@ impl CanvasEditor { .shaped .paragraphs .truncate(outline.shaped.paragraphs.len() - removed); - if outline.shaped.tables.is_empty() { + if outline.shaped.tables.is_empty() && outline.shaped.objects.is_empty() { let (origins, size) = arrange( outline.shaped.paragraphs.iter(), outline.wrap_width(), @@ -2845,6 +2848,13 @@ impl CanvasEditor { .shaped .paragraphs .iter() + .chain( + outline + .shaped + .objects + .iter() + .filter_map(|o| o.label.as_ref()), + ) .map(|paragraph| (paragraph.id, paragraph)) .collect::>(); let shaped = OutlineLayout::flow( @@ -2947,13 +2957,9 @@ impl CanvasEditor { let outline = self.active_outline(); outline.document.validate_edit(&edit)?; let width = outline.wrap_width(); - let (visible, shaped, origins, size, tables) = if outline - .document - .nodes() - .iter() - .chain(&edit.replacement) - .any(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_))) - { + let (visible, shaped, origins, size, tables, objects) = if !crate::outline::all_text( + outline.document.nodes().iter().chain(&edit.replacement), + ) { let sources = outline .document .text_nodes() @@ -3000,6 +3006,7 @@ impl CanvasEditor { origins, layout.size, layout.tables, + layout.objects, ) } else { let visible_start = outline @@ -3040,6 +3047,7 @@ impl CanvasEditor { origins, size, Vec::new(), + Vec::new(), ) }; for position in selection.positions { @@ -3090,6 +3098,7 @@ impl CanvasEditor { let inverse = outline.document.apply(edit)?; outline.shaped.paragraphs.splice(visible, shaped); outline.shaped.tables = tables; + outline.shaped.objects = objects; for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) { paragraph.origin[1] = y; } @@ -4649,6 +4658,88 @@ mod tests { assert_eq!(editor.selection().positions, caret(0, 0)); } + #[test] + fn text_edits_flow_around_a_picture_inside_an_outline() { + use onestore::page::{Image, Page, PageObject, ParagraphContent}; + let mut engine = TextEngine::default(); + let mut source = TextOutline::new( + &mut engine, + TextDocument::new(vec![ + Paragraph::new("Before".into(), Default::default()), + Paragraph::new("After".into(), Default::default()), + ]) + .unwrap(), + 240.0, + [36.0, 36.0], + ) + .unwrap() + .snapshot(); + let mut picture = source.paragraphs[0].clone(); + picture.id = onestore::page::text::new_id().unwrap(); + let image = Image { + size: Some([40.0, 30.0]), + id: onestore::page::text::new_id().unwrap(), + layout: Default::default(), + bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())), + alt: None, + background: false, + }; + picture.content = ParagraphContent::Image(image.clone()); + source.paragraphs.insert(1, picture); + let outline_id = source.id; + let mut editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + definitions: BTreeMap::new(), + objects: vec![PageObject::Outline(source)], + }, + &mut engine, + ) + .unwrap(); + let outline = editor.active_outline(); + assert_eq!(outline.id, outline_id); + let [before, after] = [0, 1].map(|i| outline.shaped().paragraphs[i].origin[1]); + let object = &outline.shaped().objects[0]; + assert_eq!(object.id, image.id); + assert_eq!(object.rect[3] - object.rect[1], 30.0); + assert!(before < object.rect[1] && object.bottom <= after); + + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + assert_eq!(editor.selection().positions[0].paragraph, 1); + editor + .move_selection(&mut engine, Movement::Up, false) + .unwrap(); + editor + .move_selection(&mut engine, Movement::LineEnd, false) + .unwrap(); + editor.insert(&mut engine, "\nMore").unwrap(); + let shaped = editor.active_outline().shaped(); + assert_eq!(shaped.paragraphs.len(), 3); + assert!(shaped.objects[0].rect[1] > shaped.paragraphs[1].origin[1]); + assert!(shaped.paragraphs[2].origin[1] >= shaped.objects[0].bottom); + // Joining across the picture is refused rather than deleting it. + editor + .move_selection(&mut engine, Movement::Down, false) + .unwrap(); + editor + .move_selection(&mut engine, Movement::LineStart, false) + .unwrap(); + assert!(!editor.delete(&mut engine, true).unwrap()); + let saved = editor.page().unwrap(); + let PageObject::Outline(saved) = &saved.objects[0] else { + panic!() + }; + assert!(matches!(&saved.paragraphs[2].content, ParagraphContent::Image(i) if *i == image)); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.active_outline().shaped().paragraphs.len(), 2); + assert_eq!(editor.active_outline().shaped().objects.len(), 1); + } + #[test] fn title_flow_moves_page_objects_atomically_and_cancels_composition() { use onestore::page::{Image, Page, PageObject, Title}; diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index c17a4021b30e3606c8c030d4bebc6929f208d68a..1cddd80596caacc18e23ed3871ba9e1559f12c9b 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -126,7 +126,7 @@ impl PageScene { let objects = crate::editor::page::build(&mut page, engine, false) .map_err(SceneError::from)? .objects; - let images = Self::decode_images(&objects)?; + let images = Self::decode_images(&objects, None)?; Ok(Self { reference: Some(objects), images, @@ -138,7 +138,7 @@ impl PageScene { engine: &mut TextEngine, ) -> Result<(Self, CanvasEditor), SceneError> { let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; - let images = Self::decode_images(&editor.objects)?; + let images = Self::decode_images(&editor.objects, Some(&editor))?; Ok(( Self { reference: None, @@ -148,28 +148,107 @@ impl PageScene { )) } + /// Decodes every picture the page draws: page-level ones and those inside outlines. fn decode_images( objects: &[Content], + editor: Option<&CanvasEditor>, ) -> Result, SceneError> { + fn nested<'a>( + nodes: &'a [onestore::page::PageParagraph], + payloads: &mut Vec<(onestore::ExGuid, Option<&'a [u8]>)>, + ) { + for node in nodes { + match &node.content { + onestore::page::ParagraphContent::Image(image) => { + payloads.push((image.id, image.bytes.as_deref())) + } + // A file without the icon OneNote rendered for it keeps an empty slot. + onestore::page::ParagraphContent::Attachment(file) => { + if let Some(icon) = file.preview.as_deref() { + payloads.push((file.id, Some(icon))) + } + } + onestore::page::ParagraphContent::Table(table) => { + for cell in table.rows.iter().flat_map(|row| &row.cells) { + nested(&cell.paragraphs, payloads); + } + } + _ => {} + } + } + } + let mut payloads = Vec::new(); + for object in objects { + match object { + Content::Image(source) => payloads.push((source.id, source.bytes.as_deref())), + Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), + Content::Editable(id) => { + if let Some(outline) = + editor.and_then(|editor| editor.outlines().iter().find(|o| o.id == *id)) + { + nested(outline.document().nodes(), &mut payloads); + } + } + Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {} + } + } let mut images = std::collections::BTreeMap::new(); let mut bytes = 0_u64; - for object in objects { - if let Content::Image(source) = object { - let image = - RasterImage::decode(source.bytes.as_deref().ok_or(SceneError::MissingImage)?) - .map_err(SceneError::Image)?; - bytes += image.pixels.as_ref().len() as u64; - if bytes > super::MAX_IMAGE_BYTES { - return Err(SceneError::Image(RenderError::ImageBudget)); - } - if images.insert(source.id, image).is_some() { - return Err(SceneError::InvalidGeometry); - } + for (id, encoded) in payloads { + let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?) + .map_err(SceneError::Image)?; + bytes += image.pixels.as_ref().len() as u64; + if bytes > super::MAX_IMAGE_BYTES { + return Err(SceneError::Image(RenderError::ImageBudget)); + } + if images.insert(id, image).is_some() { + return Err(SceneError::InvalidGeometry); } } Ok(images) } + pub fn image(&self, id: onestore::ExGuid) -> Option<&RasterImage> { + self.images.get(&id) + } + + /// Pictures, files and handwriting inside an outline whose origin is `origin`. + pub fn append_outline_objects<'a>( + &'a self, + outline: &'a crate::outline::OutlineLayout, + origin: [f32; 2], + primitives: &mut Vec>, + ) { + for object in &outline.objects { + if let Some(image) = self.images.get(&object.id) { + let [x0, y0, x1, y1] = object.rect; + primitives.push(Primitive::Image { + image, + rect: [ + x0 + origin[0], + y0 + origin[1], + x1 + origin[0], + y1 + origin[1], + ], + }); + } + if let Some(ink) = &object.ink { + append_ink( + ink, + [origin[0] + object.rect[0], origin[1] + object.rect[1]], + primitives, + ); + } + if let Some(label) = &object.label { + primitives.push(Primitive::Text { + layout: &label.text, + origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], + clip: None, + }); + } + } + } + fn objects<'a>( &'a self, editor: Option<&'a CanvasEditor>, @@ -465,6 +544,7 @@ impl PageScene { }; outline.append_table_primitives(primitives, object_origin); outline.append_background_primitives(primitives, object_origin); + self.append_outline_objects(outline, object_origin, primitives); for (index, paragraph) in outline.paragraphs.iter().enumerate() { let origin = [ object_origin[0] + paragraph.origin[0], diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 8efbc1d901a4357790156f1914d0a7682114d224..a1730e8f2a9f5c5420f44976172ae4b36a923b88 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -16,9 +16,28 @@ pub(crate) const TITLE_WIDTH: f32 = 468.0; pub struct OutlineLayout { pub paragraphs: Vec, pub tables: Vec, + /// Pictures, files and handwriting that occupy a paragraph of their own. + pub objects: Vec, pub size: [f32; 2], } +#[derive(Clone)] +pub struct ObjectLayout { + /// The picture's, file's or drawing's identity, which keys a decoded image. + pub id: ExGuid, + /// Where the picture or file icon draws, outline-local. + pub rect: [f32; 4], + /// A file's name, centered under its icon. + pub label: Option, + /// Handwriting, whose strokes are relative to the rect's top-left. + pub ink: Option, + /// Outline-local bottom of the whole object, label included. + pub bottom: f32, +} + +/// OneNote centers a file's icon and name in a column this wide. +const ATTACHMENT_WIDTH: f32 = 54.0; + #[derive(Clone)] pub struct TableLayout { pub id: ExGuid, @@ -93,6 +112,48 @@ impl ParagraphTag { pub const SIZE: f32 = 12.0; } +/// The centered caption OneNote draws under a file's icon: its name without the extension. +fn attachment_label(node: &PageParagraph, file: &onestore::page::Attachment) -> PageParagraph { + let name = std::path::Path::new(&file.filename) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(&file.filename); + PageParagraph { + id: node.id, + parent: None, + level: 1, + style: None, + format: Format::default(), + content: ParagraphContent::Text(onestore::page::TextObject { + id: file.id, + date_field: None, + text: Paragraph::new( + name.into(), + Format { + alignment: Some(1), + ..node.format.clone() + }, + ), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + } +} + +/// A picture's displayed size: the user-set layout size, else its intrinsic size. +pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { + let size = [ + image.layout.max_width.or(image.size.map(|s| s[0]))?, + image.layout.max_height.or(image.size.map(|s| s[1]))?, + ]; + size.iter() + .all(|v| v.is_finite() && *v > 0.0) + .then_some(size) +} + pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result { if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { return Err(LayoutError::InvalidIndentation); @@ -128,6 +189,17 @@ fn spacing( } impl ParagraphLayout { + pub(crate) fn reset_origin(&mut self, x: f32) { + let offset = x - self.origin[0]; + self.origin = [x, 0.0]; + for (_, origin) in &mut self.markers { + origin[0] += offset; + } + for tag in &mut self.tags { + tag.origin[0] += offset; + } + } + fn size(&self) -> [f32; 2] { [ self.origin[0] + self.text.shaped.width(), @@ -373,8 +445,19 @@ impl OutlineLayout { } } } + for object in &mut child.objects { + for (value, offset) in object.rect.iter_mut().zip(origin.into_iter().cycle()) { + *value += offset; + } + object.bottom += origin[1]; + if let Some(label) = &mut object.label { + label.reset_origin(label.origin[0] + origin[0]); + label.origin[1] += origin[1]; + } + } self.paragraphs.extend(child.paragraphs); self.tables.extend(child.tables); + self.objects.extend(child.objects); } pub(crate) fn flow<'a>( @@ -395,6 +478,7 @@ impl OutlineLayout { let mut result = Self { paragraphs: Vec::new(), tables: Vec::new(), + objects: Vec::new(), size: [36.0, 0.0], }; let mut bottom = 0.0; @@ -422,10 +506,59 @@ impl OutlineLayout { bottom += f64::from(child.size[1]); result.append(child, [x, y]); } - ParagraphContent::Image(_) - | ParagraphContent::Attachment(_) - | ParagraphContent::Ink(_) - | ParagraphContent::Unsupported(_) => { + ParagraphContent::Image(image) => { + let x = indentation(node.level, indents, width)?; + let y = spacing(&mut bottom, &mut previous, &node.format)?; + let [w, h] = image_size(image).ok_or(LayoutError::UnsupportedContent)?; + result.objects.push(ObjectLayout { + id: image.id, + rect: [x, y, x + w, y + h], + label: None, + ink: None, + bottom: y + h, + }); + result.size[0] = result.size[0].max(x + w); + bottom += f64::from(h); + } + ParagraphContent::Attachment(file) => { + let x = indentation(node.level, indents, width)?; + let y = spacing(&mut bottom, &mut previous, &node.format)?; + let [w, h] = file.size.unwrap_or([24.0, 24.0]); + let icon = [x + (ATTACHMENT_WIDTH - w) / 2.0, y + 6.0]; + let mut label = shape(&attachment_label(node, file), ATTACHMENT_WIDTH, &[0.0])?; + label.reset_origin(x); + label.origin[1] = icon[1] + h + 10.5; + let end = label.origin[1] + label.text.height() + 9.0; + result.objects.push(ObjectLayout { + id: file.id, + rect: [icon[0], icon[1], icon[0] + w, icon[1] + h], + label: Some(label), + ink: None, + bottom: end, + }); + result.size[0] = result.size[0].max(x + ATTACHMENT_WIDTH); + bottom += f64::from(end - y); + } + ParagraphContent::Ink(ink) => { + let x = indentation(node.level, indents, width)?; + let y = spacing(&mut bottom, &mut previous, &node.format)?; + // The paragraph reaches from its origin to the farthest stroke point. + let [w, h] = ink + .bounds() + .map(|[x, y, w, h]| [x + w, y + h]) + .filter(|size| size.iter().all(|v| v.is_finite() && *v >= 0.0)) + .ok_or(LayoutError::UnsupportedContent)?; + result.objects.push(ObjectLayout { + id: ink.id, + rect: [x, y, x + w, y + h], + label: None, + ink: Some(ink.clone()), + bottom: y + h, + }); + result.size[0] = result.size[0].max(x + w); + bottom += f64::from(h); + } + ParagraphContent::Unsupported(_) => { return Err(LayoutError::UnsupportedContent); } } @@ -479,6 +612,7 @@ impl OutlineLayout { cells: Vec::new(), borders: table.borders.unwrap_or(true), }], + objects: Vec::new(), size: [ (0..table.columns.len()) .map(|index| width(index) + 4.98) @@ -546,6 +680,7 @@ impl OutlineLayout { Ok(Self { paragraphs, tables: Vec::new(), + objects: Vec::new(), size, }) } @@ -578,6 +713,13 @@ impl Arrange for Outline { } } +/// Outlines of plain paragraphs stack them; tables and pictures, files or ink need the full flow. +pub(crate) fn all_text<'a>(nodes: impl IntoIterator) -> bool { + nodes + .into_iter() + .all(|node| matches!(node.content, ParagraphContent::Text(_))) +} + pub(crate) fn outline_layout( outline: &Outline, engine: &mut TextEngine, @@ -587,11 +729,7 @@ pub(crate) fn outline_layout( if !outline.unsupported.is_empty() { return Err(LayoutError::UnsupportedContent); } - if outline - .paragraphs - .iter() - .any(|node| matches!(node.content, ParagraphContent::Table(_))) - { + if !all_text(&outline.paragraphs) { return OutlineLayout::flow( outline.paragraphs.iter(), &outline.indents, @@ -1052,6 +1190,70 @@ mod tests { )); } + #[test] + fn files_and_ink_take_their_own_paragraph_in_the_flow() { + use onestore::page::{Attachment, Ink, InkStroke}; + let before = paragraph(1, "Before", 1, None); + let mut file = paragraph(2, "", 1, None); + file.content = ParagraphContent::Attachment(Attachment { + id: ExGuid { + n: 20, + ..ExGuid::default() + }, + filename: "notes 🦀.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + bytes: None, + preview: None, + recording: None, + }); + let mut ink = paragraph(3, "", 1, None); + ink.content = ParagraphContent::Ink(Ink { + id: ExGuid { + n: 30, + ..ExGuid::default() + }, + layout: Default::default(), + strokes: vec![InkStroke { + id: ExGuid::default(), + points: vec![[300.0, 120.0], [360.0, 180.0]], + width: 1.0, + height: 1.0, + color: None, + transparency: None, + pen_tip: None, + }], + groups: Vec::new(), + }); + let after = paragraph(4, "After", 1, None); + let mut engine = TextEngine::default(); + let layout = OutlineLayout::flow( + [&before, &file, &ink, &after].into_iter(), + &[0.0], + 468.0, + false, + 0, + None, + &mut |node, width, indents| { + ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new()) + }, + ) + .unwrap(); + let top = layout.paragraphs[0].text.height(); + let [icon, handwriting] = [&layout.objects[0], &layout.objects[1]]; + assert_eq!(icon.rect, [15.0, top + 6.0, 39.0, top + 30.0]); + let label = icon.label.as_ref().unwrap(); + assert_eq!(label.projection.text().text(), "notes 🦀"); + assert_eq!(label.origin, [0.0, top + 40.5]); + assert_eq!(icon.bottom, label.origin[1] + label.text.height() + 9.0); + assert_eq!( + handwriting.rect, + [0.0, icon.bottom, 360.0, icon.bottom + 180.0] + ); + assert_eq!(layout.paragraphs[1].origin[1], handwriting.bottom); + assert_eq!(layout.size[0], 360.0); + } + #[test] fn tags_paint_oldest_first_and_the_newest_colour_wins() { use onestore::document::Tag; diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 0021f084c944cb389fcf1719ae3eaab24a05f7fb..9f54d254f2521d0ffa855079da5bd94fb6a8b335 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1874,7 +1874,15 @@ fn page_primitives<'a>( scale, display_scale / scale, primitives, - ) + )?; + if let Some((scene, _)) = scene { + scene.append_outline_objects( + outline.shaped(), + [origin[0] + offset[0], origin[1] + offset[1]], + primitives, + ); + } + Ok::<_, Box>(()) }; if let Some((scene, origin)) = scene { let moving = match preview { diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 7be0fb3f880817c3e9088fd262f7ecc116fd7dde..4c26415a3f10fda5d69daa4339b0ca7fbd0a7a97 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -127,6 +127,8 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA ## Unsupported page content +Pictures, files and handwriting that occupy a paragraph lay out in the outline's flow, at the paragraph indent with its spacing. A picture draws at its user-set layout size, else its intrinsic size. A file draws as OneNote does: the icon OneNote stored for it (24 pt when unsized) centered in a 54 pt column, with its name, less the extension, wrapped and centered below; a file without a stored icon keeps the empty slot. Handwriting strokes are relative to the paragraph's top-left, which the paragraph extends to reach. All three are part of the editable outline: text navigation passes over them, edits reflow around them, a selection spanning one deletes it, and Backspace or Delete does not join text across one. + Unsupported top-level objects, outlines or titles render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The editor owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, a provisional caret appears to the right of the placeholders; typing creates an annotation outline. Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, width grips and text participate at its own position in that order before any outline’s padding or tag gutters, so a width handle stays reachable under the left padding of the outline above it. Supported titles expose editable text without move/resize handles. Pictures other than backgrounds take hits at their paint position, and a selected picture's handles take hits above everything else. Invalid geometry, malformed image data and resource-budget failures remain errors. -- 2.54.0