authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 10:50:33-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
logac8ebd9b6dfd7e1c002ec56155fc01c435562209
treeb0fd5b8c3e53034ddd83bd52fca33b204a14b224
parentf5d0249a43558a42eb7286211d167646f9eed475
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

6 files changed, 450 insertions(+), 56 deletions(-)

crates/canvas/src/document.rs+16-5
...@@ -154,12 +154,23 @@ pub(crate) fn validate_nodes(...@@ -154,12 +154,23 @@ pub(crate) fn validate_nodes(
154 return Err(EditError::InvalidStructure);154 return Err(EditError::InvalidStructure);
155 }155 }
156 }156 }
157 ParagraphContent::Image(_)157 ParagraphContent::Image(image) => {
158 | ParagraphContent::Attachment(_)158 if !ids.insert(image.id) {
159 | ParagraphContent::Ink(_)159 return Err(EditError::InvalidStructure);
160 | ParagraphContent::Unsupported(_) => {160 }
161 return Err(EditError::UnsupportedContent);161 crate::outline::image_size(image).ok_or(EditError::UnsupportedContent)?;
162 }
163 ParagraphContent::Attachment(file) => {
164 if !ids.insert(file.id) {
165 return Err(EditError::InvalidStructure);
166 }
167 }
168 ParagraphContent::Ink(ink) => {
169 if !ids.insert(ink.id) {
170 return Err(EditError::InvalidStructure);
171 }
162 }172 }
173 ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent),
163 ParagraphContent::Table(table) => {174 ParagraphContent::Table(table) => {
164 if !ids.insert(table.id)175 if !ids.insert(table.id)
165 || table.rows.is_empty()176 || table.rows.is_empty()
crates/canvas/src/editor.rs+118-27
...@@ -458,8 +458,8 @@ impl TextOutline {...@@ -458,8 +458,8 @@ impl TextOutline {
458 }458 }
459 onestore::page::ParagraphContent::Image(_)459 onestore::page::ParagraphContent::Image(_)
460 | onestore::page::ParagraphContent::Attachment(_)460 | onestore::page::ParagraphContent::Attachment(_)
461 | onestore::page::ParagraphContent::Ink(_)461 | onestore::page::ParagraphContent::Ink(_) => {}
462 | onestore::page::ParagraphContent::Unsupported(_) => {462 onestore::page::ParagraphContent::Unsupported(_) => {
463 return Err(EditError::UnsupportedContent);463 return Err(EditError::UnsupportedContent);
464 }464 }
465 }465 }
...@@ -508,10 +508,11 @@ impl TextOutline {...@@ -508,10 +508,11 @@ impl TextOutline {
508 .rect[1]508 .rect[1]
509 - 1.86509 - 1.86
510 }510 }
511 // Pictures, files and ink hold no caret; text around them takes the hit.
511 onestore::page::ParagraphContent::Image(_)512 onestore::page::ParagraphContent::Image(_)
512 | onestore::page::ParagraphContent::Attachment(_)513 | onestore::page::ParagraphContent::Attachment(_)
513 | onestore::page::ParagraphContent::Ink(_)514 | onestore::page::ParagraphContent::Ink(_) => continue,
514 | onestore::page::ParagraphContent::Unsupported(_) => {515 onestore::page::ParagraphContent::Unsupported(_) => {
515 return Err(EditError::UnsupportedContent);516 return Err(EditError::UnsupportedContent);
516 }517 }
517 };518 };
...@@ -559,17 +560,6 @@ struct Composition {...@@ -559,17 +560,6 @@ struct Composition {
559}560}
560561
561impl ParagraphLayout {562impl ParagraphLayout {
562 fn reset_origin(&mut self, x: f32) {
563 let offset = x - self.origin[0];
564 self.origin = [x, 0.0];
565 for (_, origin) in &mut self.markers {
566 origin[0] += offset;
567 }
568 for tag in &mut self.tags {
569 tag.origin[0] += offset;
570 }
571 }
572
573 fn cursor(&self, source: u32, affinity: Affinity) -> Result<Cursor, EditError> {563 fn cursor(&self, source: u32, affinity: Affinity) -> Result<Cursor, EditError> {
574 let visible = self.projection.visible_offset(source)?;564 let visible = self.projection.visible_offset(source)?;
575 let byte = self.projection.text().byte_offset(visible)?;565 let byte = self.projection.text().byte_offset(visible)?;
...@@ -1830,10 +1820,23 @@ impl CanvasEditor {...@@ -1830,10 +1820,23 @@ impl CanvasEditor {
1830 .ok_or(EditError::InvalidStructure)?;1820 .ok_or(EditError::InvalidStructure)?;
1831 f64::from(cell.rect[3]) + 1.681821 f64::from(cell.rect[3]) + 1.68
1832 }1822 }
1833 onestore::page::ParagraphContent::Image(_)1823 onestore::page::ParagraphContent::Image(onestore::page::Image {
1834 | onestore::page::ParagraphContent::Attachment(_)1824 id,
1835 | onestore::page::ParagraphContent::Ink(_)1825 ..
1836 | onestore::page::ParagraphContent::Unsupported(_) => {1826 })
1827 | onestore::page::ParagraphContent::Attachment(
1828 onestore::page::Attachment { id, .. },
1829 )
1830 | onestore::page::ParagraphContent::Ink(onestore::page::Ink {
1831 id, ..
1832 }) => outline
1833 .shaped
1834 .objects
1835 .iter()
1836 .find(|object| object.id == *id)
1837 .map(|object| f64::from(object.bottom))
1838 .ok_or(EditError::InvalidStructure)?,
1839 onestore::page::ParagraphContent::Unsupported(_) => {
1837 return Err(EditError::UnsupportedContent.into());1840 return Err(EditError::UnsupportedContent.into());
1838 }1841 }
1839 } + f64::from(outline.origin()[1]);1842 } + f64::from(outline.origin()[1]);
...@@ -2829,7 +2832,7 @@ impl CanvasEditor {...@@ -2829,7 +2832,7 @@ impl CanvasEditor {
2829 .shaped2832 .shaped
2830 .paragraphs2833 .paragraphs
2831 .truncate(outline.shaped.paragraphs.len() - removed);2834 .truncate(outline.shaped.paragraphs.len() - removed);
2832 if outline.shaped.tables.is_empty() {2835 if outline.shaped.tables.is_empty() && outline.shaped.objects.is_empty() {
2833 let (origins, size) = arrange(2836 let (origins, size) = arrange(
2834 outline.shaped.paragraphs.iter(),2837 outline.shaped.paragraphs.iter(),
2835 outline.wrap_width(),2838 outline.wrap_width(),
...@@ -2845,6 +2848,13 @@ impl CanvasEditor {...@@ -2845,6 +2848,13 @@ impl CanvasEditor {
2845 .shaped2848 .shaped
2846 .paragraphs2849 .paragraphs
2847 .iter()2850 .iter()
2851 .chain(
2852 outline
2853 .shaped
2854 .objects
2855 .iter()
2856 .filter_map(|o| o.label.as_ref()),
2857 )
2848 .map(|paragraph| (paragraph.id, paragraph))2858 .map(|paragraph| (paragraph.id, paragraph))
2849 .collect::<BTreeMap<_, _>>();2859 .collect::<BTreeMap<_, _>>();
2850 let shaped = OutlineLayout::flow(2860 let shaped = OutlineLayout::flow(
...@@ -2947,13 +2957,9 @@ impl CanvasEditor {...@@ -2947,13 +2957,9 @@ impl CanvasEditor {
2947 let outline = self.active_outline();2957 let outline = self.active_outline();
2948 outline.document.validate_edit(&edit)?;2958 outline.document.validate_edit(&edit)?;
2949 let width = outline.wrap_width();2959 let width = outline.wrap_width();
2950 let (visible, shaped, origins, size, tables) = if outline2960 let (visible, shaped, origins, size, tables, objects) = if !crate::outline::all_text(
2951 .document2961 outline.document.nodes().iter().chain(&edit.replacement),
2952 .nodes()2962 ) {
2953 .iter()
2954 .chain(&edit.replacement)
2955 .any(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_)))
2956 {
2957 let sources = outline2963 let sources = outline
2958 .document2964 .document
2959 .text_nodes()2965 .text_nodes()
...@@ -3000,6 +3006,7 @@ impl CanvasEditor {...@@ -3000,6 +3006,7 @@ impl CanvasEditor {
3000 origins,3006 origins,
3001 layout.size,3007 layout.size,
3002 layout.tables,3008 layout.tables,
3009 layout.objects,
3003 )3010 )
3004 } else {3011 } else {
3005 let visible_start = outline3012 let visible_start = outline
...@@ -3040,6 +3047,7 @@ impl CanvasEditor {...@@ -3040,6 +3047,7 @@ impl CanvasEditor {
3040 origins,3047 origins,
3041 size,3048 size,
3042 Vec::new(),3049 Vec::new(),
3050 Vec::new(),
3043 )3051 )
3044 };3052 };
3045 for position in selection.positions {3053 for position in selection.positions {
...@@ -3090,6 +3098,7 @@ impl CanvasEditor {...@@ -3090,6 +3098,7 @@ impl CanvasEditor {
3090 let inverse = outline.document.apply(edit)?;3098 let inverse = outline.document.apply(edit)?;
3091 outline.shaped.paragraphs.splice(visible, shaped);3099 outline.shaped.paragraphs.splice(visible, shaped);
3092 outline.shaped.tables = tables;3100 outline.shaped.tables = tables;
3101 outline.shaped.objects = objects;
3093 for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) {3102 for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) {
3094 paragraph.origin[1] = y;3103 paragraph.origin[1] = y;
3095 }3104 }
...@@ -4649,6 +4658,88 @@ mod tests {...@@ -4649,6 +4658,88 @@ mod tests {
4649 assert_eq!(editor.selection().positions, caret(0, 0));4658 assert_eq!(editor.selection().positions, caret(0, 0));
4650 }4659 }
46514660
4661 #[test]
4662 fn text_edits_flow_around_a_picture_inside_an_outline() {
4663 use onestore::page::{Image, Page, PageObject, ParagraphContent};
4664 let mut engine = TextEngine::default();
4665 let mut source = TextOutline::new(
4666 &mut engine,
4667 TextDocument::new(vec![
4668 Paragraph::new("Before".into(), Default::default()),
4669 Paragraph::new("After".into(), Default::default()),
4670 ])
4671 .unwrap(),
4672 240.0,
4673 [36.0, 36.0],
4674 )
4675 .unwrap()
4676 .snapshot();
4677 let mut picture = source.paragraphs[0].clone();
4678 picture.id = onestore::page::text::new_id().unwrap();
4679 let image = Image {
4680 size: Some([40.0, 30.0]),
4681 id: onestore::page::text::new_id().unwrap(),
4682 layout: Default::default(),
4683 bytes: Some(std::sync::Arc::from(b"deferred image payload".as_slice())),
4684 alt: None,
4685 background: false,
4686 };
4687 picture.content = ParagraphContent::Image(image.clone());
4688 source.paragraphs.insert(1, picture);
4689 let outline_id = source.id;
4690 let mut editor = CanvasEditor::from_page(
4691 Page {
4692 title: String::new(),
4693 identity: None,
4694 created: None,
4695 margin_origin: [36.0, 14.4],
4696 definitions: BTreeMap::new(),
4697 objects: vec![PageObject::Outline(source)],
4698 },
4699 &mut engine,
4700 )
4701 .unwrap();
4702 let outline = editor.active_outline();
4703 assert_eq!(outline.id, outline_id);
4704 let [before, after] = [0, 1].map(|i| outline.shaped().paragraphs[i].origin[1]);
4705 let object = &outline.shaped().objects[0];
4706 assert_eq!(object.id, image.id);
4707 assert_eq!(object.rect[3] - object.rect[1], 30.0);
4708 assert!(before < object.rect[1] && object.bottom <= after);
4709
4710 editor
4711 .move_selection(&mut engine, Movement::Down, false)
4712 .unwrap();
4713 assert_eq!(editor.selection().positions[0].paragraph, 1);
4714 editor
4715 .move_selection(&mut engine, Movement::Up, false)
4716 .unwrap();
4717 editor
4718 .move_selection(&mut engine, Movement::LineEnd, false)
4719 .unwrap();
4720 editor.insert(&mut engine, "\nMore").unwrap();
4721 let shaped = editor.active_outline().shaped();
4722 assert_eq!(shaped.paragraphs.len(), 3);
4723 assert!(shaped.objects[0].rect[1] > shaped.paragraphs[1].origin[1]);
4724 assert!(shaped.paragraphs[2].origin[1] >= shaped.objects[0].bottom);
4725 // Joining across the picture is refused rather than deleting it.
4726 editor
4727 .move_selection(&mut engine, Movement::Down, false)
4728 .unwrap();
4729 editor
4730 .move_selection(&mut engine, Movement::LineStart, false)
4731 .unwrap();
4732 assert!(!editor.delete(&mut engine, true).unwrap());
4733 let saved = editor.page().unwrap();
4734 let PageObject::Outline(saved) = &saved.objects[0] else {
4735 panic!()
4736 };
4737 assert!(matches!(&saved.paragraphs[2].content, ParagraphContent::Image(i) if *i == image));
4738 assert!(editor.undo(&mut engine).unwrap());
4739 assert_eq!(editor.active_outline().shaped().paragraphs.len(), 2);
4740 assert_eq!(editor.active_outline().shaped().objects.len(), 1);
4741 }
4742
4652 #[test]4743 #[test]
4653 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {4744 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {
4654 use onestore::page::{Image, Page, PageObject, Title};4745 use onestore::page::{Image, Page, PageObject, Title};
crates/canvas/src/gpu/page.rs+94-14
...@@ -126,7 +126,7 @@ impl PageScene {...@@ -126,7 +126,7 @@ impl PageScene {
126 let objects = crate::editor::page::build(&mut page, engine, false)126 let objects = crate::editor::page::build(&mut page, engine, false)
127 .map_err(SceneError::from)?127 .map_err(SceneError::from)?
128 .objects;128 .objects;
129 let images = Self::decode_images(&objects)?;129 let images = Self::decode_images(&objects, None)?;
130 Ok(Self {130 Ok(Self {
131 reference: Some(objects),131 reference: Some(objects),
132 images,132 images,
...@@ -138,7 +138,7 @@ impl PageScene {...@@ -138,7 +138,7 @@ impl PageScene {
138 engine: &mut TextEngine,138 engine: &mut TextEngine,
139 ) -> Result<(Self, CanvasEditor), SceneError> {139 ) -> Result<(Self, CanvasEditor), SceneError> {
140 let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?;140 let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?;
141 let images = Self::decode_images(&editor.objects)?;141 let images = Self::decode_images(&editor.objects, Some(&editor))?;
142 Ok((142 Ok((
143 Self {143 Self {
144 reference: None,144 reference: None,
...@@ -148,28 +148,107 @@ impl PageScene {...@@ -148,28 +148,107 @@ impl PageScene {
148 ))148 ))
149 }149 }
150150
151 /// Decodes every picture the page draws: page-level ones and those inside outlines.
151 fn decode_images(152 fn decode_images(
152 objects: &[Content],153 objects: &[Content],
154 editor: Option<&CanvasEditor>,
153 ) -> Result<std::collections::BTreeMap<onestore::ExGuid, RasterImage>, SceneError> {155 ) -> Result<std::collections::BTreeMap<onestore::ExGuid, RasterImage>, SceneError> {
154 let mut images = std::collections::BTreeMap::new();156 fn nested<'a>(
155 let mut bytes = 0_u64;157 nodes: &'a [onestore::page::PageParagraph],
156 for object in objects {158 payloads: &mut Vec<(onestore::ExGuid, Option<&'a [u8]>)>,
157 if let Content::Image(source) = object {159 ) {
158 let image =160 for node in nodes {
159 RasterImage::decode(source.bytes.as_deref().ok_or(SceneError::MissingImage)?)161 match &node.content {
160 .map_err(SceneError::Image)?;162 onestore::page::ParagraphContent::Image(image) => {
161 bytes += image.pixels.as_ref().len() as u64;163 payloads.push((image.id, image.bytes.as_deref()))
162 if bytes > super::MAX_IMAGE_BYTES {164 }
163 return Err(SceneError::Image(RenderError::ImageBudget));165 // A file without the icon OneNote rendered for it keeps an empty slot.
166 onestore::page::ParagraphContent::Attachment(file) => {
167 if let Some(icon) = file.preview.as_deref() {
168 payloads.push((file.id, Some(icon)))
169 }
170 }
171 onestore::page::ParagraphContent::Table(table) => {
172 for cell in table.rows.iter().flat_map(|row| &row.cells) {
173 nested(&cell.paragraphs, payloads);
174 }
175 }
176 _ => {}
164 }177 }
165 if images.insert(source.id, image).is_some() {178 }
166 return Err(SceneError::InvalidGeometry);179 }
180 let mut payloads = Vec::new();
181 for object in objects {
182 match object {
183 Content::Image(source) => payloads.push((source.id, source.bytes.as_deref())),
184 Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads),
185 Content::Editable(id) => {
186 if let Some(outline) =
187 editor.and_then(|editor| editor.outlines().iter().find(|o| o.id == *id))
188 {
189 nested(outline.document().nodes(), &mut payloads);
190 }
167 }191 }
192 Content::Date { .. } | Content::Ink(_) | Content::ReadOnly(_) => {}
193 }
194 }
195 let mut images = std::collections::BTreeMap::new();
196 let mut bytes = 0_u64;
197 for (id, encoded) in payloads {
198 let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?)
199 .map_err(SceneError::Image)?;
200 bytes += image.pixels.as_ref().len() as u64;
201 if bytes > super::MAX_IMAGE_BYTES {
202 return Err(SceneError::Image(RenderError::ImageBudget));
203 }
204 if images.insert(id, image).is_some() {
205 return Err(SceneError::InvalidGeometry);
168 }206 }
169 }207 }
170 Ok(images)208 Ok(images)
171 }209 }
172210
211 pub fn image(&self, id: onestore::ExGuid) -> Option<&RasterImage> {
212 self.images.get(&id)
213 }
214
215 /// Pictures, files and handwriting inside an outline whose origin is `origin`.
216 pub fn append_outline_objects<'a>(
217 &'a self,
218 outline: &'a crate::outline::OutlineLayout,
219 origin: [f32; 2],
220 primitives: &mut Vec<Primitive<'a>>,
221 ) {
222 for object in &outline.objects {
223 if let Some(image) = self.images.get(&object.id) {
224 let [x0, y0, x1, y1] = object.rect;
225 primitives.push(Primitive::Image {
226 image,
227 rect: [
228 x0 + origin[0],
229 y0 + origin[1],
230 x1 + origin[0],
231 y1 + origin[1],
232 ],
233 });
234 }
235 if let Some(ink) = &object.ink {
236 append_ink(
237 ink,
238 [origin[0] + object.rect[0], origin[1] + object.rect[1]],
239 primitives,
240 );
241 }
242 if let Some(label) = &object.label {
243 primitives.push(Primitive::Text {
244 layout: &label.text,
245 origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]],
246 clip: None,
247 });
248 }
249 }
250 }
251
173 fn objects<'a>(252 fn objects<'a>(
174 &'a self,253 &'a self,
175 editor: Option<&'a CanvasEditor>,254 editor: Option<&'a CanvasEditor>,
...@@ -465,6 +544,7 @@ impl PageScene {...@@ -465,6 +544,7 @@ impl PageScene {
465 };544 };
466 outline.append_table_primitives(primitives, object_origin);545 outline.append_table_primitives(primitives, object_origin);
467 outline.append_background_primitives(primitives, object_origin);546 outline.append_background_primitives(primitives, object_origin);
547 self.append_outline_objects(outline, object_origin, primitives);
468 for (index, paragraph) in outline.paragraphs.iter().enumerate() {548 for (index, paragraph) in outline.paragraphs.iter().enumerate() {
469 let origin = [549 let origin = [
470 object_origin[0] + paragraph.origin[0],550 object_origin[0] + paragraph.origin[0],
crates/canvas/src/outline.rs+211-9
...@@ -16,9 +16,28 @@ pub(crate) const TITLE_WIDTH: f32 = 468.0;...@@ -16,9 +16,28 @@ pub(crate) const TITLE_WIDTH: f32 = 468.0;
16pub struct OutlineLayout {16pub struct OutlineLayout {
17 pub paragraphs: Vec<ParagraphLayout>,17 pub paragraphs: Vec<ParagraphLayout>,
18 pub tables: Vec<TableLayout>,18 pub tables: Vec<TableLayout>,
19 /// Pictures, files and handwriting that occupy a paragraph of their own.
20 pub objects: Vec<ObjectLayout>,
19 pub size: [f32; 2],21 pub size: [f32; 2],
20}22}
2123
24#[derive(Clone)]
25pub struct ObjectLayout {
26 /// The picture's, file's or drawing's identity, which keys a decoded image.
27 pub id: ExGuid,
28 /// Where the picture or file icon draws, outline-local.
29 pub rect: [f32; 4],
30 /// A file's name, centered under its icon.
31 pub label: Option<ParagraphLayout>,
32 /// Handwriting, whose strokes are relative to the rect's top-left.
33 pub ink: Option<onestore::page::Ink>,
34 /// Outline-local bottom of the whole object, label included.
35 pub bottom: f32,
36}
37
38/// OneNote centers a file's icon and name in a column this wide.
39const ATTACHMENT_WIDTH: f32 = 54.0;
40
22#[derive(Clone)]41#[derive(Clone)]
23pub struct TableLayout {42pub struct TableLayout {
24 pub id: ExGuid,43 pub id: ExGuid,
...@@ -93,6 +112,48 @@ impl ParagraphTag {...@@ -93,6 +112,48 @@ impl ParagraphTag {
93 pub const SIZE: f32 = 12.0;112 pub const SIZE: f32 = 12.0;
94}113}
95114
115/// The centered caption OneNote draws under a file's icon: its name without the extension.
116fn attachment_label(node: &PageParagraph, file: &onestore::page::Attachment) -> PageParagraph {
117 let name = std::path::Path::new(&file.filename)
118 .file_stem()
119 .and_then(|stem| stem.to_str())
120 .unwrap_or(&file.filename);
121 PageParagraph {
122 id: node.id,
123 parent: None,
124 level: 1,
125 style: None,
126 format: Format::default(),
127 content: ParagraphContent::Text(onestore::page::TextObject {
128 id: file.id,
129 date_field: None,
130 text: Paragraph::new(
131 name.into(),
132 Format {
133 alignment: Some(1),
134 ..node.format.clone()
135 },
136 ),
137 tags: Vec::new(),
138 }),
139 lists: Vec::new(),
140 tags: Vec::new(),
141 media: Default::default(),
142 collapsed: false,
143 }
144}
145
146/// A picture's displayed size: the user-set layout size, else its intrinsic size.
147pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> {
148 let size = [
149 image.layout.max_width.or(image.size.map(|s| s[0]))?,
150 image.layout.max_height.or(image.size.map(|s| s[1]))?,
151 ];
152 size.iter()
153 .all(|v| v.is_finite() && *v > 0.0)
154 .then_some(size)
155}
156
96pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {157pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {
97 if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {158 if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {
98 return Err(LayoutError::InvalidIndentation);159 return Err(LayoutError::InvalidIndentation);
...@@ -128,6 +189,17 @@ fn spacing(...@@ -128,6 +189,17 @@ fn spacing(
128}189}
129190
130impl ParagraphLayout {191impl ParagraphLayout {
192 pub(crate) fn reset_origin(&mut self, x: f32) {
193 let offset = x - self.origin[0];
194 self.origin = [x, 0.0];
195 for (_, origin) in &mut self.markers {
196 origin[0] += offset;
197 }
198 for tag in &mut self.tags {
199 tag.origin[0] += offset;
200 }
201 }
202
131 fn size(&self) -> [f32; 2] {203 fn size(&self) -> [f32; 2] {
132 [204 [
133 self.origin[0] + self.text.shaped.width(),205 self.origin[0] + self.text.shaped.width(),
...@@ -373,8 +445,19 @@ impl OutlineLayout {...@@ -373,8 +445,19 @@ impl OutlineLayout {
373 }445 }
374 }446 }
375 }447 }
448 for object in &mut child.objects {
449 for (value, offset) in object.rect.iter_mut().zip(origin.into_iter().cycle()) {
450 *value += offset;
451 }
452 object.bottom += origin[1];
453 if let Some(label) = &mut object.label {
454 label.reset_origin(label.origin[0] + origin[0]);
455 label.origin[1] += origin[1];
456 }
457 }
376 self.paragraphs.extend(child.paragraphs);458 self.paragraphs.extend(child.paragraphs);
377 self.tables.extend(child.tables);459 self.tables.extend(child.tables);
460 self.objects.extend(child.objects);
378 }461 }
379462
380 pub(crate) fn flow<'a>(463 pub(crate) fn flow<'a>(
...@@ -395,6 +478,7 @@ impl OutlineLayout {...@@ -395,6 +478,7 @@ impl OutlineLayout {
395 let mut result = Self {478 let mut result = Self {
396 paragraphs: Vec::new(),479 paragraphs: Vec::new(),
397 tables: Vec::new(),480 tables: Vec::new(),
481 objects: Vec::new(),
398 size: [36.0, 0.0],482 size: [36.0, 0.0],
399 };483 };
400 let mut bottom = 0.0;484 let mut bottom = 0.0;
...@@ -422,10 +506,59 @@ impl OutlineLayout {...@@ -422,10 +506,59 @@ impl OutlineLayout {
422 bottom += f64::from(child.size[1]);506 bottom += f64::from(child.size[1]);
423 result.append(child, [x, y]);507 result.append(child, [x, y]);
424 }508 }
425 ParagraphContent::Image(_)509 ParagraphContent::Image(image) => {
426 | ParagraphContent::Attachment(_)510 let x = indentation(node.level, indents, width)?;
427 | ParagraphContent::Ink(_)511 let y = spacing(&mut bottom, &mut previous, &node.format)?;
428 | ParagraphContent::Unsupported(_) => {512 let [w, h] = image_size(image).ok_or(LayoutError::UnsupportedContent)?;
513 result.objects.push(ObjectLayout {
514 id: image.id,
515 rect: [x, y, x + w, y + h],
516 label: None,
517 ink: None,
518 bottom: y + h,
519 });
520 result.size[0] = result.size[0].max(x + w);
521 bottom += f64::from(h);
522 }
523 ParagraphContent::Attachment(file) => {
524 let x = indentation(node.level, indents, width)?;
525 let y = spacing(&mut bottom, &mut previous, &node.format)?;
526 let [w, h] = file.size.unwrap_or([24.0, 24.0]);
527 let icon = [x + (ATTACHMENT_WIDTH - w) / 2.0, y + 6.0];
528 let mut label = shape(&attachment_label(node, file), ATTACHMENT_WIDTH, &[0.0])?;
529 label.reset_origin(x);
530 label.origin[1] = icon[1] + h + 10.5;
531 let end = label.origin[1] + label.text.height() + 9.0;
532 result.objects.push(ObjectLayout {
533 id: file.id,
534 rect: [icon[0], icon[1], icon[0] + w, icon[1] + h],
535 label: Some(label),
536 ink: None,
537 bottom: end,
538 });
539 result.size[0] = result.size[0].max(x + ATTACHMENT_WIDTH);
540 bottom += f64::from(end - y);
541 }
542 ParagraphContent::Ink(ink) => {
543 let x = indentation(node.level, indents, width)?;
544 let y = spacing(&mut bottom, &mut previous, &node.format)?;
545 // The paragraph reaches from its origin to the farthest stroke point.
546 let [w, h] = ink
547 .bounds()
548 .map(|[x, y, w, h]| [x + w, y + h])
549 .filter(|size| size.iter().all(|v| v.is_finite() && *v >= 0.0))
550 .ok_or(LayoutError::UnsupportedContent)?;
551 result.objects.push(ObjectLayout {
552 id: ink.id,
553 rect: [x, y, x + w, y + h],
554 label: None,
555 ink: Some(ink.clone()),
556 bottom: y + h,
557 });
558 result.size[0] = result.size[0].max(x + w);
559 bottom += f64::from(h);
560 }
561 ParagraphContent::Unsupported(_) => {
429 return Err(LayoutError::UnsupportedContent);562 return Err(LayoutError::UnsupportedContent);
430 }563 }
431 }564 }
...@@ -479,6 +612,7 @@ impl OutlineLayout {...@@ -479,6 +612,7 @@ impl OutlineLayout {
479 cells: Vec::new(),612 cells: Vec::new(),
480 borders: table.borders.unwrap_or(true),613 borders: table.borders.unwrap_or(true),
481 }],614 }],
615 objects: Vec::new(),
482 size: [616 size: [
483 (0..table.columns.len())617 (0..table.columns.len())
484 .map(|index| width(index) + 4.98)618 .map(|index| width(index) + 4.98)
...@@ -546,6 +680,7 @@ impl OutlineLayout {...@@ -546,6 +680,7 @@ impl OutlineLayout {
546 Ok(Self {680 Ok(Self {
547 paragraphs,681 paragraphs,
548 tables: Vec::new(),682 tables: Vec::new(),
683 objects: Vec::new(),
549 size,684 size,
550 })685 })
551 }686 }
...@@ -578,6 +713,13 @@ impl Arrange for Outline {...@@ -578,6 +713,13 @@ impl Arrange for Outline {
578 }713 }
579}714}
580715
716/// Outlines of plain paragraphs stack them; tables and pictures, files or ink need the full flow.
717pub(crate) fn all_text<'a>(nodes: impl IntoIterator<Item = &'a PageParagraph>) -> bool {
718 nodes
719 .into_iter()
720 .all(|node| matches!(node.content, ParagraphContent::Text(_)))
721}
722
581pub(crate) fn outline_layout(723pub(crate) fn outline_layout(
582 outline: &Outline,724 outline: &Outline,
583 engine: &mut TextEngine,725 engine: &mut TextEngine,
...@@ -587,11 +729,7 @@ pub(crate) fn outline_layout(...@@ -587,11 +729,7 @@ pub(crate) fn outline_layout(
587 if !outline.unsupported.is_empty() {729 if !outline.unsupported.is_empty() {
588 return Err(LayoutError::UnsupportedContent);730 return Err(LayoutError::UnsupportedContent);
589 }731 }
590 if outline732 if !all_text(&outline.paragraphs) {
591 .paragraphs
592 .iter()
593 .any(|node| matches!(node.content, ParagraphContent::Table(_)))
594 {
595 return OutlineLayout::flow(733 return OutlineLayout::flow(
596 outline.paragraphs.iter(),734 outline.paragraphs.iter(),
597 &outline.indents,735 &outline.indents,
...@@ -1052,6 +1190,70 @@ mod tests {...@@ -1052,6 +1190,70 @@ mod tests {
1052 ));1190 ));
1053 }1191 }
10541192
1193 #[test]
1194 fn files_and_ink_take_their_own_paragraph_in_the_flow() {
1195 use onestore::page::{Attachment, Ink, InkStroke};
1196 let before = paragraph(1, "Before", 1, None);
1197 let mut file = paragraph(2, "", 1, None);
1198 file.content = ParagraphContent::Attachment(Attachment {
1199 id: ExGuid {
1200 n: 20,
1201 ..ExGuid::default()
1202 },
1203 filename: "notes 🦀.txt".into(),
1204 source_path: None,
1205 size: Some([24.0, 24.0]),
1206 bytes: None,
1207 preview: None,
1208 recording: None,
1209 });
1210 let mut ink = paragraph(3, "", 1, None);
1211 ink.content = ParagraphContent::Ink(Ink {
1212 id: ExGuid {
1213 n: 30,
1214 ..ExGuid::default()
1215 },
1216 layout: Default::default(),
1217 strokes: vec![InkStroke {
1218 id: ExGuid::default(),
1219 points: vec![[300.0, 120.0], [360.0, 180.0]],
1220 width: 1.0,
1221 height: 1.0,
1222 color: None,
1223 transparency: None,
1224 pen_tip: None,
1225 }],
1226 groups: Vec::new(),
1227 });
1228 let after = paragraph(4, "After", 1, None);
1229 let mut engine = TextEngine::default();
1230 let layout = OutlineLayout::flow(
1231 [&before, &file, &ink, &after].into_iter(),
1232 &[0.0],
1233 468.0,
1234 false,
1235 0,
1236 None,
1237 &mut |node, width, indents| {
1238 ParagraphLayout::shape(&mut engine, node, width, indents, &BTreeMap::new())
1239 },
1240 )
1241 .unwrap();
1242 let top = layout.paragraphs[0].text.height();
1243 let [icon, handwriting] = [&layout.objects[0], &layout.objects[1]];
1244 assert_eq!(icon.rect, [15.0, top + 6.0, 39.0, top + 30.0]);
1245 let label = icon.label.as_ref().unwrap();
1246 assert_eq!(label.projection.text().text(), "notes 🦀");
1247 assert_eq!(label.origin, [0.0, top + 40.5]);
1248 assert_eq!(icon.bottom, label.origin[1] + label.text.height() + 9.0);
1249 assert_eq!(
1250 handwriting.rect,
1251 [0.0, icon.bottom, 360.0, icon.bottom + 180.0]
1252 );
1253 assert_eq!(layout.paragraphs[1].origin[1], handwriting.bottom);
1254 assert_eq!(layout.size[0], 360.0);
1255 }
1256
1055 #[test]1257 #[test]
1056 fn tags_paint_oldest_first_and_the_newest_colour_wins() {1258 fn tags_paint_oldest_first_and_the_newest_colour_wins() {
1057 use onestore::document::Tag;1259 use onestore::document::Tag;
crates/snowbound/src/main.rs+9-1
...@@ -1874,7 +1874,15 @@ fn page_primitives<'a>(...@@ -1874,7 +1874,15 @@ fn page_primitives<'a>(
1874 scale,1874 scale,
1875 display_scale / scale,1875 display_scale / scale,
1876 primitives,1876 primitives,
1877 )1877 )?;
1878 if let Some((scene, _)) = scene {
1879 scene.append_outline_objects(
1880 outline.shaped(),
1881 [origin[0] + offset[0], origin[1] + offset[1]],
1882 primitives,
1883 );
1884 }
1885 Ok::<_, Box<dyn Error>>(())
1878 };1886 };
1879 if let Some((scene, origin)) = scene {1887 if let Some((scene, origin)) = scene {
1880 let moving = match preview {1888 let moving = match preview {
tools/canvas/README.md+2
...@@ -127,6 +127,8 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA...@@ -127,6 +127,8 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA
127127
128## Unsupported page content128## Unsupported page content
129129
130Pictures, 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.
131
130Unsupported 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.132Unsupported 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.
131133
132Placeholders 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.134Placeholders 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.