| author | |
| committer | |
| log | ac8ebd9b6dfd7e1c002ec56155fc01c435562209 |
| tree | b0fd5b8c3e53034ddd83bd52fca33b204a14b224 |
| parent | f5d0249a43558a42eb7286211d167646f9eed475 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.56 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.86 | 509 | - 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 | } |
| 560 | 561 | ||
| 561 | impl ParagraphLayout { | 562 | impl 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.68 | 1821 | 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 | .shaped | 2832 | .shaped |
| 2830 | .paragraphs | 2833 | .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 | .shaped | 2848 | .shaped |
| 2846 | .paragraphs | 2849 | .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 outline | 2960 | let (visible, shaped, origins, size, tables, objects) = if !crate::outline::all_text( |
| 2951 | .document | 2961 | 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 = outline | 2963 | let sources = outline |
| 2958 | .document | 2964 | .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 = outline | 3012 | 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 | } |
| 4651 | 4660 | ||
| 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 | } |
| 150 | 150 | ||
| 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 | } |
| 172 | 210 | ||
| 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; |
| 16 | pub struct OutlineLayout { | 16 | pub 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 | } |
| 21 | 23 | ||
| 24 | #[derive(Clone)] | ||
| 25 | pub 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. | ||
| 39 | const ATTACHMENT_WIDTH: f32 = 54.0; | ||
| 40 | |||
| 22 | #[derive(Clone)] | 41 | #[derive(Clone)] |
| 23 | pub struct TableLayout { | 42 | pub 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 | } |
| 95 | 114 | ||
| 115 | /// The centered caption OneNote draws under a file's icon: its name without the extension. | ||
| 116 | fn 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. | ||
| 147 | pub(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 | |||
| 96 | pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> { | 157 | pub(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 | } |
| 129 | 190 | ||
| 130 | impl ParagraphLayout { | 191 | impl 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 | } |
| 379 | 462 | ||
| 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 | } |
| 580 | 715 | ||
| 716 | /// Outlines of plain paragraphs stack them; tables and pictures, files or ink need the full flow. | ||
| 717 | pub(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 | |||
| 581 | pub(crate) fn outline_layout( | 723 | pub(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 outline | 732 | 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 | } |
| 1054 | 1192 | ||
| 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 |
| 127 | 127 | ||
| 128 | ## Unsupported page content | 128 | ## Unsupported page content |
| 129 | 129 | ||
| 130 | 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. | ||
| 131 | |||
| 130 | 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. | 132 | 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. |
| 131 | 133 | ||
| 132 | 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. | 134 | 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. |