diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index c392e2579d005ad05c2e6297b77cdbefd6ee10b6..c29b8789cb0ee4fe092e0dc0d5389d997206f15a 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -686,6 +686,7 @@ impl CanvasEditor { .iter() .filter_map(|object| match object { page::Content::ReadOnly(object) => Some(object.rect()[2]), + page::Content::Ink(ink) => page::ink_bounds(ink).map(|b| b[2]), _ => None, }) .fold(0.0_f32, f32::max); @@ -740,6 +741,10 @@ impl CanvasEditor { objects.push(PageObject::Image(image.clone())); continue; } + page::Content::Ink(ink) => { + objects.push(PageObject::Ink(ink.clone())); + continue; + } page::Content::ReadOnly(object) => { objects.push(object.source.clone()); continue; diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index d6494f37fdeb1bc91049b295332ad40402616355..21cf184034497b388b5843ff2c515ead597b3c51 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -5,7 +5,7 @@ use crate::{ outline::{Arrange, OutlineLayout}, }; use onestore::page::text::Paragraph; -use onestore::page::{Image, Outline, Page, PageObject}; +use onestore::page::{Image, Ink, Outline, Page, PageObject}; use std::collections::BTreeMap; /// A title object's own state, plus the child origins `build` replaces with page coordinates. @@ -33,6 +33,7 @@ pub(crate) enum Content { below_title: Option, }, Image(Image), + Ink(Ink), Editable(onestore::ExGuid), ReadOnly(Box), } @@ -50,6 +51,7 @@ impl Content { match self { Self::Outline { source, .. } => Some((source.id, &source.layout)), Self::Image(source) => Some((source.id, &source.layout)), + Self::Ink(source) => Some((source.id, &source.layout)), Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())), Self::Date { .. } | Self::Editable(_) => None, } @@ -62,7 +64,8 @@ impl Content { Self::Outline { source, .. } => Some((source.id, &mut source.layout)), Self::Image(source) => Some((source.id, &mut source.layout)), Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())), - Self::Date { .. } | Self::Editable(_) => None, + // Strokes carry their own page coordinates, so a layout move would not move them. + Self::Date { .. } | Self::Ink(_) | Self::Editable(_) => None, } } } @@ -80,15 +83,10 @@ impl ReadOnlyObject { [0.0; 2] }; let layout = source.layout(); - // Ink carries no layout of its own; its strokes say where it is. - let extent = match &source { - PageObject::Ink(ink) => ink.bounds(), - _ => None, - }; - let x = extent.map_or(layout.x.unwrap_or(0.0), |e| e[0]) + offset[0]; - let y = extent.map_or(layout.y.unwrap_or(0.0), |e| e[1]) + offset[1]; - let width = extent.map_or(layout.max_width.unwrap_or(160.0), |e| e[2]); - let height = extent.map_or(layout.max_height.unwrap_or(42.0), |e| e[3]); + let x = layout.x.unwrap_or(0.0) + offset[0]; + let y = layout.y.unwrap_or(0.0) + offset[1]; + let width = layout.max_width.unwrap_or(160.0); + let height = layout.max_height.unwrap_or(42.0); if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { return Err(EditorError::InvalidGeometry); } @@ -326,12 +324,15 @@ pub(crate) fn build( }; objects.push(Content::Image(source)); } - PageObject::Ink(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( - object, - page.margin_origin, - "Ink\nRead-only", - engine, - )?)), + PageObject::Ink(ink) => { + if ink_bounds(ink).is_some_and(|b| b.iter().any(|v| !v.is_finite())) { + return Err(EditorError::InvalidGeometry); + } + let PageObject::Ink(ink) = object else { + unreachable!() + }; + objects.push(Content::Ink(ink)) + } PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( object, page.margin_origin, @@ -347,3 +348,25 @@ pub(crate) fn build( areas, }) } + +/// The painted extent of every stroke, pen included, as `[x0, y0, x1, y1]` page points. +pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { + ink.strokes + .iter() + .flat_map(|stroke| { + let r = stroke.width.max(stroke.height) * 0.5; + stroke + .points + .iter() + .map(move |[x, y]| [x - r, y - r, x + r, y + r]) + }) + .chain(ink.groups.iter().filter_map(ink_bounds)) + .reduce(|a, b| { + [ + a[0].min(b[0]), + a[1].min(b[1]), + a[2].max(b[2]), + a[3].max(b[3]), + ] + }) +} diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index b4c3792dc55992c4cc58e4585f414869a6fa1c6f..b6be28d86ce9186148a217007cecc4172d4c842b 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -217,6 +217,15 @@ pub enum Primitive<'a> { image: &'a RasterImage, rect: [f32; 4], }, + /// A pen stroke between two points, `width` points across. + Segment { + from: [f32; 2], + to: [f32; 2], + width: f32, + /// A round pen tip caps the ends; otherwise they are square. + round: bool, + color: [f32; 4], + }, } #[derive(Debug)] @@ -578,6 +587,13 @@ impl Renderer { vertex.stroke = width; } } + Primitive::Segment { + from, + to, + width, + round, + color, + } => self.segment(viewport, *from, *to, *width, *round, *color)?, Primitive::Image { image, rect } => { if let Some(rect) = viewport.visible_image_rect(*rect)? { self.image(image, active_images)?; @@ -943,6 +959,76 @@ impl Renderer { ) } + /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. + fn segment( + &mut self, + viewport: Viewport, + from: [f32; 2], + to: [f32; 2], + width: f32, + round: bool, + color: [f32; 4], + ) -> Result<(), RenderError> { + let pixel = |p: [f32; 2]| { + [ + p[0] * viewport.scale + viewport.origin[0], + p[1] * viewport.scale + viewport.origin[1], + ] + }; + let [from, to] = [pixel(from), pixel(to)]; + // Hairlines stay one device pixel wide, as OneNote draws its thinnest pen. + let radius = (width * viewport.scale).max(1.0) * 0.5; + if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { + return Err(RenderError::InvalidPrimitive); + } + let pad = radius + 1.0; + if from[0].max(to[0]) + pad < 0.0 + || from[1].max(to[1]) + pad < 0.0 + || from[0].min(to[0]) - pad > viewport.size[0] as f32 + || from[1].min(to[1]) - pad > viewport.size[1] as f32 + { + return Ok(()); + } + if self.vertices.len() + 6 > MAX_VERTICES { + return Err(RenderError::FrameTooLarge); + } + let delta = [to[0] - from[0], to[1] - from[1]]; + let length = delta[0].hypot(delta[1]); + let along = if length > 0.0 { + [delta[0] / length, delta[1] / length] + } else { + [1.0, 0.0] + }; + let across = [-along[1], along[0]]; + let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; + let half = [length * 0.5 + radius, radius]; + let corner = if round { radius } else { 0.0 }; + let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; + for local in [ + [-hx, -hy], + [-hx, hy], + [hx, hy], + [-hx, -hy], + [hx, hy], + [hx, -hy], + ] { + let x = center[0] + along[0] * local[0] + across[0] * local[1]; + let y = center[1] + along[1] * local[0] + across[1] * local[1]; + self.vertices.push(Vertex { + position: [ + x * 2.0 / viewport.size[0] as f32 - 1.0, + 1.0 - y * 2.0 / viewport.size[1] as f32, + ], + uv: [0.5 / ATLAS_SIZE as f32; 2], + color, + local, + shape: [half[0], half[1], corner, corner], + stroke: 0.0, + }); + } + Ok(()) + } + fn quad( &mut self, viewport: Viewport, @@ -1241,6 +1327,13 @@ mod tests { tag: &tags[3], origin: [188.125, 80.25], }, + Primitive::Segment { + from: [226.0, 104.0], + to: [240.0, 104.0], + width: 4.0, + round: true, + color: [1.0, 0.0, 0.0, 1.0], + }, ]; primitives.extend((0..9).map(|step| Primitive::Text { clip: None, @@ -1325,6 +1418,10 @@ mod tests { }) .collect(); let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(490, 232), [255, 0, 0, 255]); + assert_eq!(pixel(473, 232), [255, 0, 0, 255]); + assert_eq!(pixel(490, 238), [255; 4]); + assert_eq!(pixel(472, 228), [255; 4]); assert_eq!(pixel(300, 140), [255, 0, 0, 255]); assert_eq!(pixel(280, 120), [255; 4]); assert_eq!(pixel(282, 124), [255; 4]); diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 0ebe062029f95ef8c77271be67b9e81661b201ef..b31dcb60fe45be827dcec0e7f6756c499cf13853 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -37,6 +37,50 @@ fn outline_origin( Ok(origin) } +fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec>) { + for stroke in &ink.strokes { + let mut color = colorref(stroke.color.unwrap_or(0)); + color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0; + let width = stroke.width.max(stroke.height); + let round = stroke.pen_tip != Some(1); + let mut points = stroke + .points + .iter() + .map(|[x, y]| [x + offset[0], y + offset[1]]); + let Some(mut from) = points.next() else { + continue; + }; + let mut drawn = false; + for to in points { + // Pen samples far closer than a pixel add vertices without changing the stroke. + if (to[0] - from[0]).hypot(to[1] - from[1]) < 0.2 { + continue; + } + primitives.push(Primitive::Segment { + from, + to, + width, + round, + color, + }); + from = to; + drawn = true; + } + if !drawn { + primitives.push(Primitive::Segment { + from, + to: from, + width, + round, + color, + }); + } + } + for group in &ink.groups { + append_ink(group, offset, primitives); + } +} + #[derive(Debug)] pub enum SceneError { Layout(LayoutError), @@ -204,6 +248,7 @@ impl PageScene { source.layout.x.unwrap_or(0.0) + source.layout.max_width?, source.layout.y.unwrap_or(0.0) + source.layout.max_height?, ]), + Content::Ink(ink) => crate::editor::page::ink_bounds(ink), Content::ReadOnly(object) => Some(object.rect()), Content::Editable(_) => None, }) @@ -297,7 +342,7 @@ impl PageScene { return Some(SceneHit::Image(source.id)); } } - Content::Outline { .. } | Content::Image(_) => {} + Content::Outline { .. } | Content::Image(_) | Content::Ink(_) => {} } } None @@ -381,6 +426,10 @@ impl PageScene { outline(*id, offset, primitives)?; continue; } + Content::Ink(ink) => { + append_ink(ink, offset, primitives); + continue; + } }; let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; match content { @@ -453,7 +502,7 @@ impl PageScene { } } } - Content::Editable(_) | Content::ReadOnly(_) => unreachable!(), + Content::Editable(_) | Content::ReadOnly(_) | Content::Ink(_) => unreachable!(), } } Ok(()) @@ -1522,6 +1571,75 @@ mod tests { )); } + #[test] + fn ink_draws_every_stroke_and_bounds_include_the_pen() { + use onestore::page::{Ink, InkStroke}; + let stroke = |points: Vec<[f32; 2]>, color| InkStroke { + id: ExGuid::default(), + points, + width: 2.0, + height: 2.0, + color, + transparency: Some(51), + pen_tip: None, + }; + let ink = Ink { + id: ExGuid::default(), + layout: Layout::default(), + // A straight vertical line has no width of its own. + strokes: vec![stroke(vec![[10.0, 20.0], [10.0, 20.1], [10.0, 60.0]], None)], + groups: vec![Ink { + id: ExGuid::default(), + layout: Layout::default(), + strokes: vec![stroke(vec![[40.0, 30.0]], Some(0x0000ff))], + groups: Vec::new(), + }], + }; + let page = Page { + identity: None, + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects: vec![PageObject::Ink(ink.clone())], + }; + let mut engine = TextEngine::default(); + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + assert!( + scene + .content_bounds(&editor) + .any(|bounds| bounds == [9.0, 19.0, 41.0, 61.0]) + ); + let mut primitives = Vec::new(); + scene + .append_primitives_with::( + &mut primitives, + [5.0, 0.0], + Some(&editor), + None, + |_, _, _| Ok(()), + ) + .unwrap(); + let segments: Vec<_> = primitives + .iter() + .filter_map(|primitive| match primitive { + Primitive::Segment { + from, to, color, .. + } => Some((*from, *to, *color)), + _ => None, + }) + .collect(); + let red = colorref(0x0000ff); + assert_eq!( + segments, + [ + ([15.0, 20.0], [15.0, 60.0], [0.0, 0.0, 0.0, 0.8]), + ([45.0, 30.0], [45.0, 30.0], [red[0], red[1], red[2], 0.8]), + ] + ); + assert_eq!(editor.page().unwrap().objects, [PageObject::Ink(ink)]); + } + #[test] fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { let mut encoded = Vec::new(); diff --git a/tools/canvas/README.md b/tools/canvas/README.md index cc4e0456eb8b6f3e5734d8abb27e7568b57ab139..7fb0d689143b42e54a2a506f74e82e6888754dfb 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -131,6 +131,8 @@ Unsupported top-level objects, outlines or titles render as read-only placeholde 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. +Page-level ink draws every stroke, nested groups included, at its stored page coordinates: each segment is a capsule (round pen tip) or a square-ended bar (rectangle tip) at the pen width, never thinner than one device pixel, in the stroke colour with its transparency. Ink extends the scroll bounds by its painted extent and saves unchanged; it takes no hits and does not move with the outlines around it. + Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction. ## Owned page import