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(
154154 return Err(EditError::InvalidStructure);
155155 }
156156 }
157 ParagraphContent::Image(_)
158 | ParagraphContent::Attachment(_)
159 | ParagraphContent::Ink(_)
160 | ParagraphContent::Unsupported(_) => {
161 return Err(EditError::UnsupportedContent);
157 ParagraphContent::Image(image) => {
158 if !ids.insert(image.id) {
159 return Err(EditError::InvalidStructure);
160 }
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 }
162172 }
173 ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent),
163174 ParagraphContent::Table(table) => {
164175 if !ids.insert(table.id)
165176 || table.rows.is_empty()
crates/canvas/src/editor.rs+118-27
......@@ -458,8 +458,8 @@ impl TextOutline {
458458 }
459459 onestore::page::ParagraphContent::Image(_)
460460 | onestore::page::ParagraphContent::Attachment(_)
461 | onestore::page::ParagraphContent::Ink(_)
462 | onestore::page::ParagraphContent::Unsupported(_) => {
461 | onestore::page::ParagraphContent::Ink(_) => {}
462 onestore::page::ParagraphContent::Unsupported(_) => {
463463 return Err(EditError::UnsupportedContent);
464464 }
465465 }
......@@ -508,10 +508,11 @@ impl TextOutline {
508508 .rect[1]
509509 - 1.86
510510 }
511 // Pictures, files and ink hold no caret; text around them takes the hit.
511512 onestore::page::ParagraphContent::Image(_)
512513 | onestore::page::ParagraphContent::Attachment(_)
513 | onestore::page::ParagraphContent::Ink(_)
514 | onestore::page::ParagraphContent::Unsupported(_) => {
514 | onestore::page::ParagraphContent::Ink(_) => continue,
515 onestore::page::ParagraphContent::Unsupported(_) => {
515516 return Err(EditError::UnsupportedContent);
516517 }
517518 };
......@@ -559,17 +560,6 @@ struct Composition {
559560}
560561
561562impl 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
573563 fn cursor(&self, source: u32, affinity: Affinity) -> Result<Cursor, EditError> {
574564 let visible = self.projection.visible_offset(source)?;
575565 let byte = self.projection.text().byte_offset(visible)?;
......@@ -1830,10 +1820,23 @@ impl CanvasEditor {
18301820 .ok_or(EditError::InvalidStructure)?;
18311821 f64::from(cell.rect[3]) + 1.68
18321822 }
1833 onestore::page::ParagraphContent::Image(_)
1834 | onestore::page::ParagraphContent::Attachment(_)
1835 | onestore::page::ParagraphContent::Ink(_)
1836 | onestore::page::ParagraphContent::Unsupported(_) => {
1823 onestore::page::ParagraphContent::Image(onestore::page::Image {
1824 id,
1825 ..
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(_) => {
18371840 return Err(EditError::UnsupportedContent.into());
18381841 }
18391842 } + f64::from(outline.origin()[1]);
......@@ -2829,7 +2832,7 @@ impl CanvasEditor {
28292832 .shaped
28302833 .paragraphs
28312834 .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() {
28332836 let (origins, size) = arrange(
28342837 outline.shaped.paragraphs.iter(),
28352838 outline.wrap_width(),
......@@ -2845,6 +2848,13 @@ impl CanvasEditor {
28452848 .shaped
28462849 .paragraphs
28472850 .iter()
2851 .chain(
2852 outline
2853 .shaped
2854 .objects
2855 .iter()
2856 .filter_map(|o| o.label.as_ref()),
2857 )
28482858 .map(|paragraph| (paragraph.id, paragraph))
28492859 .collect::<BTreeMap<_, _>>();
28502860 let shaped = OutlineLayout::flow(
......@@ -2947,13 +2957,9 @@ impl CanvasEditor {
29472957 let outline = self.active_outline();
29482958 outline.document.validate_edit(&edit)?;
29492959 let width = outline.wrap_width();
2950 let (visible, shaped, origins, size, tables) = if outline
2951 .document
2952 .nodes()
2953 .iter()
2954 .chain(&edit.replacement)
2955 .any(|node| matches!(node.content, onestore::page::ParagraphContent::Table(_)))
2956 {
2960 let (visible, shaped, origins, size, tables, objects) = if !crate::outline::all_text(
2961 outline.document.nodes().iter().chain(&edit.replacement),
2962 ) {
29572963 let sources = outline
29582964 .document
29592965 .text_nodes()
......@@ -3000,6 +3006,7 @@ impl CanvasEditor {
30003006 origins,
30013007 layout.size,
30023008 layout.tables,
3009 layout.objects,
30033010 )
30043011 } else {
30053012 let visible_start = outline
......@@ -3040,6 +3047,7 @@ impl CanvasEditor {
30403047 origins,
30413048 size,
30423049 Vec::new(),
3050 Vec::new(),
30433051 )
30443052 };
30453053 for position in selection.positions {
......@@ -3090,6 +3098,7 @@ impl CanvasEditor {
30903098 let inverse = outline.document.apply(edit)?;
30913099 outline.shaped.paragraphs.splice(visible, shaped);
30923100 outline.shaped.tables = tables;
3101 outline.shaped.objects = objects;
30933102 for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) {
30943103 paragraph.origin[1] = y;
30953104 }
......@@ -4649,6 +4658,88 @@ mod tests {
46494658 assert_eq!(editor.selection().positions, caret(0, 0));
46504659 }
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
46524743 #[test]
46534744 fn title_flow_moves_page_objects_atomically_and_cancels_composition() {
46544745 use onestore::page::{Image, Page, PageObject, Title};
crates/canvas/src/gpu/page.rs+94-14
......@@ -126,7 +126,7 @@ impl PageScene {
126126 let objects = crate::editor::page::build(&mut page, engine, false)
127127 .map_err(SceneError::from)?
128128 .objects;
129 let images = Self::decode_images(&objects)?;
129 let images = Self::decode_images(&objects, None)?;
130130 Ok(Self {
131131 reference: Some(objects),
132132 images,
......@@ -138,7 +138,7 @@ impl PageScene {
138138 engine: &mut TextEngine,
139139 ) -> Result<(Self, CanvasEditor), SceneError> {
140140 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))?;
142142 Ok((
143143 Self {
144144 reference: None,
......@@ -148,28 +148,107 @@ impl PageScene {
148148 ))
149149 }
150150
151 /// Decodes every picture the page draws: page-level ones and those inside outlines.
151152 fn decode_images(
152153 objects: &[Content],
154 editor: Option<&CanvasEditor>,
153155 ) -> Result<std::collections::BTreeMap<onestore::ExGuid, RasterImage>, SceneError> {
154 let mut images = std::collections::BTreeMap::new();
155 let mut bytes = 0_u64;
156 for object in objects {
157 if let Content::Image(source) = object {
158 let image =
159 RasterImage::decode(source.bytes.as_deref().ok_or(SceneError::MissingImage)?)
160 .map_err(SceneError::Image)?;
161 bytes += image.pixels.as_ref().len() as u64;
162 if bytes > super::MAX_IMAGE_BYTES {
163 return Err(SceneError::Image(RenderError::ImageBudget));
156 fn nested<'a>(
157 nodes: &'a [onestore::page::PageParagraph],
158 payloads: &mut Vec<(onestore::ExGuid, Option<&'a [u8]>)>,
159 ) {
160 for node in nodes {
161 match &node.content {
162 onestore::page::ParagraphContent::Image(image) => {
163 payloads.push((image.id, image.bytes.as_deref()))
164 }
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 _ => {}
164177 }
165 if images.insert(source.id, image).is_some() {
166 return Err(SceneError::InvalidGeometry);
178 }
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 }
167191 }
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);
168206 }
169207 }
170208 Ok(images)
171209 }
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
173252 fn objects<'a>(
174253 &'a self,
175254 editor: Option<&'a CanvasEditor>,
......@@ -465,6 +544,7 @@ impl PageScene {
465544 };
466545 outline.append_table_primitives(primitives, object_origin);
467546 outline.append_background_primitives(primitives, object_origin);
547 self.append_outline_objects(outline, object_origin, primitives);
468548 for (index, paragraph) in outline.paragraphs.iter().enumerate() {
469549 let origin = [
470550 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;
1616pub struct OutlineLayout {
1717 pub paragraphs: Vec<ParagraphLayout>,
1818 pub tables: Vec<TableLayout>,
19 /// Pictures, files and handwriting that occupy a paragraph of their own.
20 pub objects: Vec<ObjectLayout>,
1921 pub size: [f32; 2],
2022}
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
2241#[derive(Clone)]
2342pub struct TableLayout {
2443 pub id: ExGuid,
......@@ -93,6 +112,48 @@ impl ParagraphTag {
93112 pub const SIZE: f32 = 12.0;
94113}
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
96157pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> {
97158 if indents.is_empty() || indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 {
98159 return Err(LayoutError::InvalidIndentation);
......@@ -128,6 +189,17 @@ fn spacing(
128189}
129190
130191impl 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
131203 fn size(&self) -> [f32; 2] {
132204 [
133205 self.origin[0] + self.text.shaped.width(),
......@@ -373,8 +445,19 @@ impl OutlineLayout {
373445 }
374446 }
375447 }
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 }
376458 self.paragraphs.extend(child.paragraphs);
377459 self.tables.extend(child.tables);
460 self.objects.extend(child.objects);
378461 }
379462
380463 pub(crate) fn flow<'a>(
......@@ -395,6 +478,7 @@ impl OutlineLayout {
395478 let mut result = Self {
396479 paragraphs: Vec::new(),
397480 tables: Vec::new(),
481 objects: Vec::new(),
398482 size: [36.0, 0.0],
399483 };
400484 let mut bottom = 0.0;
......@@ -422,10 +506,59 @@ impl OutlineLayout {
422506 bottom += f64::from(child.size[1]);
423507 result.append(child, [x, y]);
424508 }
425 ParagraphContent::Image(_)
426 | ParagraphContent::Attachment(_)
427 | ParagraphContent::Ink(_)
428 | ParagraphContent::Unsupported(_) => {
509 ParagraphContent::Image(image) => {
510 let x = indentation(node.level, indents, width)?;
511 let y = spacing(&mut bottom, &mut previous, &node.format)?;
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(_) => {
429562 return Err(LayoutError::UnsupportedContent);
430563 }
431564 }
......@@ -479,6 +612,7 @@ impl OutlineLayout {
479612 cells: Vec::new(),
480613 borders: table.borders.unwrap_or(true),
481614 }],
615 objects: Vec::new(),
482616 size: [
483617 (0..table.columns.len())
484618 .map(|index| width(index) + 4.98)
......@@ -546,6 +680,7 @@ impl OutlineLayout {
546680 Ok(Self {
547681 paragraphs,
548682 tables: Vec::new(),
683 objects: Vec::new(),
549684 size,
550685 })
551686 }
......@@ -578,6 +713,13 @@ impl Arrange for Outline {
578713 }
579714}
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
581723pub(crate) fn outline_layout(
582724 outline: &Outline,
583725 engine: &mut TextEngine,
......@@ -587,11 +729,7 @@ pub(crate) fn outline_layout(
587729 if !outline.unsupported.is_empty() {
588730 return Err(LayoutError::UnsupportedContent);
589731 }
590 if outline
591 .paragraphs
592 .iter()
593 .any(|node| matches!(node.content, ParagraphContent::Table(_)))
594 {
732 if !all_text(&outline.paragraphs) {
595733 return OutlineLayout::flow(
596734 outline.paragraphs.iter(),
597735 &outline.indents,
......@@ -1052,6 +1190,70 @@ mod tests {
10521190 ));
10531191 }
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
10551257 #[test]
10561258 fn tags_paint_oldest_first_and_the_newest_colour_wins() {
10571259 use onestore::document::Tag;
crates/snowbound/src/main.rs+9-1
......@@ -1874,7 +1874,15 @@ fn page_primitives<'a>(
18741874 scale,
18751875 display_scale / scale,
18761876 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>>(())
18781886 };
18791887 if let Some((scene, origin)) = scene {
18801888 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
127127
128128## 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
130132Unsupported 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
132134Placeholders 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.