| author | |
| committer | |
| log | 9954b152f54c18a0150bbf7ef92fc56b4c23d2a8 |
| tree | e8a53a68e43fb5fb38c5b4b6c7be88a3fa317893 |
| parent | 2632a55ddb21bfc41f307e6d85abff189f9823dd |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Page-level ink rendered as a read-only placeholder, and "s: late at night" did not open at
all: its one stroke is a straight vertical line, so the placeholder had zero width. Strokes
now draw as pen-width capsules (square-ended for a rectangle tip) in their colour and
transparency, reusing the rounded-rectangle distance in a rotated frame, and count toward
the scroll bounds. Positions and colours match native captures of corpus/ink-edit.
Assisted-by: claude-opus-5.55 files changed, 264 insertions(+), 19 deletions(-)
crates/canvas/src/editor.rs+5| ... | @@ -686,6 +686,7 @@ impl CanvasEditor { | ... | @@ -686,6 +686,7 @@ impl CanvasEditor { |
| 686 | .iter() | 686 | .iter() |
| 687 | .filter_map(|object| match object { | 687 | .filter_map(|object| match object { |
| 688 | page::Content::ReadOnly(object) => Some(object.rect()[2]), | 688 | page::Content::ReadOnly(object) => Some(object.rect()[2]), |
| 689 | page::Content::Ink(ink) => page::ink_bounds(ink).map(|b| b[2]), | ||
| 689 | _ => None, | 690 | _ => None, |
| 690 | }) | 691 | }) |
| 691 | .fold(0.0_f32, f32::max); | 692 | .fold(0.0_f32, f32::max); |
| ... | @@ -740,6 +741,10 @@ impl CanvasEditor { | ... | @@ -740,6 +741,10 @@ impl CanvasEditor { |
| 740 | objects.push(PageObject::Image(image.clone())); | 741 | objects.push(PageObject::Image(image.clone())); |
| 741 | continue; | 742 | continue; |
| 742 | } | 743 | } |
| 744 | page::Content::Ink(ink) => { | ||
| 745 | objects.push(PageObject::Ink(ink.clone())); | ||
| 746 | continue; | ||
| 747 | } | ||
| 743 | page::Content::ReadOnly(object) => { | 748 | page::Content::ReadOnly(object) => { |
| 744 | objects.push(object.source.clone()); | 749 | objects.push(object.source.clone()); |
| 745 | continue; | 750 | continue; |
crates/canvas/src/editor/page.rs+40-17| ... | @@ -5,7 +5,7 @@ use crate::{ | ... | @@ -5,7 +5,7 @@ use crate::{ |
| 5 | outline::{Arrange, OutlineLayout}, | 5 | outline::{Arrange, OutlineLayout}, |
| 6 | }; | 6 | }; |
| 7 | use onestore::page::text::Paragraph; | 7 | use onestore::page::text::Paragraph; |
| 8 | use onestore::page::{Image, Outline, Page, PageObject}; | 8 | use onestore::page::{Image, Ink, Outline, Page, PageObject}; |
| 9 | use std::collections::BTreeMap; | 9 | use std::collections::BTreeMap; |
| 10 | 10 | ||
| 11 | /// A title object's own state, plus the child origins `build` replaces with page coordinates. | 11 | /// A title object's own state, plus the child origins `build` replaces with page coordinates. |
| ... | @@ -33,6 +33,7 @@ pub(crate) enum Content { | ... | @@ -33,6 +33,7 @@ pub(crate) enum Content { |
| 33 | below_title: Option<onestore::ExGuid>, | 33 | below_title: Option<onestore::ExGuid>, |
| 34 | }, | 34 | }, |
| 35 | Image(Image), | 35 | Image(Image), |
| 36 | Ink(Ink), | ||
| 36 | Editable(onestore::ExGuid), | 37 | Editable(onestore::ExGuid), |
| 37 | ReadOnly(Box<ReadOnlyObject>), | 38 | ReadOnly(Box<ReadOnlyObject>), |
| 38 | } | 39 | } |
| ... | @@ -50,6 +51,7 @@ impl Content { | ... | @@ -50,6 +51,7 @@ impl Content { |
| 50 | match self { | 51 | match self { |
| 51 | Self::Outline { source, .. } => Some((source.id, &source.layout)), | 52 | Self::Outline { source, .. } => Some((source.id, &source.layout)), |
| 52 | Self::Image(source) => Some((source.id, &source.layout)), | 53 | Self::Image(source) => Some((source.id, &source.layout)), |
| 54 | Self::Ink(source) => Some((source.id, &source.layout)), | ||
| 53 | Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())), | 55 | Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())), |
| 54 | Self::Date { .. } | Self::Editable(_) => None, | 56 | Self::Date { .. } | Self::Editable(_) => None, |
| 55 | } | 57 | } |
| ... | @@ -62,7 +64,8 @@ impl Content { | ... | @@ -62,7 +64,8 @@ impl Content { |
| 62 | Self::Outline { source, .. } => Some((source.id, &mut source.layout)), | 64 | Self::Outline { source, .. } => Some((source.id, &mut source.layout)), |
| 63 | Self::Image(source) => Some((source.id, &mut source.layout)), | 65 | Self::Image(source) => Some((source.id, &mut source.layout)), |
| 64 | Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())), | 66 | Self::ReadOnly(object) => Some((object.source.id(), object.source.layout_mut())), |
| 65 | Self::Date { .. } | Self::Editable(_) => None, | 67 | // Strokes carry their own page coordinates, so a layout move would not move them. |
| 68 | Self::Date { .. } | Self::Ink(_) | Self::Editable(_) => None, | ||
| 66 | } | 69 | } |
| 67 | } | 70 | } |
| 68 | } | 71 | } |
| ... | @@ -80,15 +83,10 @@ impl ReadOnlyObject { | ... | @@ -80,15 +83,10 @@ impl ReadOnlyObject { |
| 80 | [0.0; 2] | 83 | [0.0; 2] |
| 81 | }; | 84 | }; |
| 82 | let layout = source.layout(); | 85 | let layout = source.layout(); |
| 83 | // Ink carries no layout of its own; its strokes say where it is. | 86 | let x = layout.x.unwrap_or(0.0) + offset[0]; |
| 84 | let extent = match &source { | 87 | let y = layout.y.unwrap_or(0.0) + offset[1]; |
| 85 | PageObject::Ink(ink) => ink.bounds(), | 88 | let width = layout.max_width.unwrap_or(160.0); |
| 86 | _ => None, | 89 | let height = layout.max_height.unwrap_or(42.0); |
| 87 | }; | ||
| 88 | let x = extent.map_or(layout.x.unwrap_or(0.0), |e| e[0]) + offset[0]; | ||
| 89 | let y = extent.map_or(layout.y.unwrap_or(0.0), |e| e[1]) + offset[1]; | ||
| 90 | let width = extent.map_or(layout.max_width.unwrap_or(160.0), |e| e[2]); | ||
| 91 | let height = extent.map_or(layout.max_height.unwrap_or(42.0), |e| e[3]); | ||
| 92 | if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { | 90 | if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { |
| 93 | return Err(EditorError::InvalidGeometry); | 91 | return Err(EditorError::InvalidGeometry); |
| 94 | } | 92 | } |
| ... | @@ -326,12 +324,15 @@ pub(crate) fn build( | ... | @@ -326,12 +324,15 @@ pub(crate) fn build( |
| 326 | }; | 324 | }; |
| 327 | objects.push(Content::Image(source)); | 325 | objects.push(Content::Image(source)); |
| 328 | } | 326 | } |
| 329 | PageObject::Ink(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( | 327 | PageObject::Ink(ink) => { |
| 330 | object, | 328 | if ink_bounds(ink).is_some_and(|b| b.iter().any(|v| !v.is_finite())) { |
| 331 | page.margin_origin, | 329 | return Err(EditorError::InvalidGeometry); |
| 332 | "Ink\nRead-only", | 330 | } |
| 333 | engine, | 331 | let PageObject::Ink(ink) = object else { |
| 334 | )?)), | 332 | unreachable!() |
| 333 | }; | ||
| 334 | objects.push(Content::Ink(ink)) | ||
| 335 | } | ||
| 335 | PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( | 336 | PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( |
| 336 | object, | 337 | object, |
| 337 | page.margin_origin, | 338 | page.margin_origin, |
| ... | @@ -347,3 +348,25 @@ pub(crate) fn build( | ... | @@ -347,3 +348,25 @@ pub(crate) fn build( |
| 347 | areas, | 348 | areas, |
| 348 | }) | 349 | }) |
| 349 | } | 350 | } |
| 351 | |||
| 352 | /// The painted extent of every stroke, pen included, as `[x0, y0, x1, y1]` page points. | ||
| 353 | pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { | ||
| 354 | ink.strokes | ||
| 355 | .iter() | ||
| 356 | .flat_map(|stroke| { | ||
| 357 | let r = stroke.width.max(stroke.height) * 0.5; | ||
| 358 | stroke | ||
| 359 | .points | ||
| 360 | .iter() | ||
| 361 | .map(move |[x, y]| [x - r, y - r, x + r, y + r]) | ||
| 362 | }) | ||
| 363 | .chain(ink.groups.iter().filter_map(ink_bounds)) | ||
| 364 | .reduce(|a, b| { | ||
| 365 | [ | ||
| 366 | a[0].min(b[0]), | ||
| 367 | a[1].min(b[1]), | ||
| 368 | a[2].max(b[2]), | ||
| 369 | a[3].max(b[3]), | ||
| 370 | ] | ||
| 371 | }) | ||
| 372 | } |
crates/canvas/src/gpu/mod.rs+97| ... | @@ -217,6 +217,15 @@ pub enum Primitive<'a> { | ... | @@ -217,6 +217,15 @@ pub enum Primitive<'a> { |
| 217 | image: &'a RasterImage, | 217 | image: &'a RasterImage, |
| 218 | rect: [f32; 4], | 218 | rect: [f32; 4], |
| 219 | }, | 219 | }, |
| 220 | /// A pen stroke between two points, `width` points across. | ||
| 221 | Segment { | ||
| 222 | from: [f32; 2], | ||
| 223 | to: [f32; 2], | ||
| 224 | width: f32, | ||
| 225 | /// A round pen tip caps the ends; otherwise they are square. | ||
| 226 | round: bool, | ||
| 227 | color: [f32; 4], | ||
| 228 | }, | ||
| 220 | } | 229 | } |
| 221 | 230 | ||
| 222 | #[derive(Debug)] | 231 | #[derive(Debug)] |
| ... | @@ -578,6 +587,13 @@ impl Renderer { | ... | @@ -578,6 +587,13 @@ impl Renderer { |
| 578 | vertex.stroke = width; | 587 | vertex.stroke = width; |
| 579 | } | 588 | } |
| 580 | } | 589 | } |
| 590 | Primitive::Segment { | ||
| 591 | from, | ||
| 592 | to, | ||
| 593 | width, | ||
| 594 | round, | ||
| 595 | color, | ||
| 596 | } => self.segment(viewport, *from, *to, *width, *round, *color)?, | ||
| 581 | Primitive::Image { image, rect } => { | 597 | Primitive::Image { image, rect } => { |
| 582 | if let Some(rect) = viewport.visible_image_rect(*rect)? { | 598 | if let Some(rect) = viewport.visible_image_rect(*rect)? { |
| 583 | self.image(image, active_images)?; | 599 | self.image(image, active_images)?; |
| ... | @@ -943,6 +959,76 @@ impl Renderer { | ... | @@ -943,6 +959,76 @@ impl Renderer { |
| 943 | ) | 959 | ) |
| 944 | } | 960 | } |
| 945 | 961 | ||
| 962 | /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. | ||
| 963 | fn segment( | ||
| 964 | &mut self, | ||
| 965 | viewport: Viewport, | ||
| 966 | from: [f32; 2], | ||
| 967 | to: [f32; 2], | ||
| 968 | width: f32, | ||
| 969 | round: bool, | ||
| 970 | color: [f32; 4], | ||
| 971 | ) -> Result<(), RenderError> { | ||
| 972 | let pixel = |p: [f32; 2]| { | ||
| 973 | [ | ||
| 974 | p[0] * viewport.scale + viewport.origin[0], | ||
| 975 | p[1] * viewport.scale + viewport.origin[1], | ||
| 976 | ] | ||
| 977 | }; | ||
| 978 | let [from, to] = [pixel(from), pixel(to)]; | ||
| 979 | // Hairlines stay one device pixel wide, as OneNote draws its thinnest pen. | ||
| 980 | let radius = (width * viewport.scale).max(1.0) * 0.5; | ||
| 981 | if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { | ||
| 982 | return Err(RenderError::InvalidPrimitive); | ||
| 983 | } | ||
| 984 | let pad = radius + 1.0; | ||
| 985 | if from[0].max(to[0]) + pad < 0.0 | ||
| 986 | || from[1].max(to[1]) + pad < 0.0 | ||
| 987 | || from[0].min(to[0]) - pad > viewport.size[0] as f32 | ||
| 988 | || from[1].min(to[1]) - pad > viewport.size[1] as f32 | ||
| 989 | { | ||
| 990 | return Ok(()); | ||
| 991 | } | ||
| 992 | if self.vertices.len() + 6 > MAX_VERTICES { | ||
| 993 | return Err(RenderError::FrameTooLarge); | ||
| 994 | } | ||
| 995 | let delta = [to[0] - from[0], to[1] - from[1]]; | ||
| 996 | let length = delta[0].hypot(delta[1]); | ||
| 997 | let along = if length > 0.0 { | ||
| 998 | [delta[0] / length, delta[1] / length] | ||
| 999 | } else { | ||
| 1000 | [1.0, 0.0] | ||
| 1001 | }; | ||
| 1002 | let across = [-along[1], along[0]]; | ||
| 1003 | let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; | ||
| 1004 | let half = [length * 0.5 + radius, radius]; | ||
| 1005 | let corner = if round { radius } else { 0.0 }; | ||
| 1006 | let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; | ||
| 1007 | for local in [ | ||
| 1008 | [-hx, -hy], | ||
| 1009 | [-hx, hy], | ||
| 1010 | [hx, hy], | ||
| 1011 | [-hx, -hy], | ||
| 1012 | [hx, hy], | ||
| 1013 | [hx, -hy], | ||
| 1014 | ] { | ||
| 1015 | let x = center[0] + along[0] * local[0] + across[0] * local[1]; | ||
| 1016 | let y = center[1] + along[1] * local[0] + across[1] * local[1]; | ||
| 1017 | self.vertices.push(Vertex { | ||
| 1018 | position: [ | ||
| 1019 | x * 2.0 / viewport.size[0] as f32 - 1.0, | ||
| 1020 | 1.0 - y * 2.0 / viewport.size[1] as f32, | ||
| 1021 | ], | ||
| 1022 | uv: [0.5 / ATLAS_SIZE as f32; 2], | ||
| 1023 | color, | ||
| 1024 | local, | ||
| 1025 | shape: [half[0], half[1], corner, corner], | ||
| 1026 | stroke: 0.0, | ||
| 1027 | }); | ||
| 1028 | } | ||
| 1029 | Ok(()) | ||
| 1030 | } | ||
| 1031 | |||
| 946 | fn quad( | 1032 | fn quad( |
| 947 | &mut self, | 1033 | &mut self, |
| 948 | viewport: Viewport, | 1034 | viewport: Viewport, |
| ... | @@ -1241,6 +1327,13 @@ mod tests { | ... | @@ -1241,6 +1327,13 @@ mod tests { |
| 1241 | tag: &tags[3], | 1327 | tag: &tags[3], |
| 1242 | origin: [188.125, 80.25], | 1328 | origin: [188.125, 80.25], |
| 1243 | }, | 1329 | }, |
| 1330 | Primitive::Segment { | ||
| 1331 | from: [226.0, 104.0], | ||
| 1332 | to: [240.0, 104.0], | ||
| 1333 | width: 4.0, | ||
| 1334 | round: true, | ||
| 1335 | color: [1.0, 0.0, 0.0, 1.0], | ||
| 1336 | }, | ||
| 1244 | ]; | 1337 | ]; |
| 1245 | primitives.extend((0..9).map(|step| Primitive::Text { | 1338 | primitives.extend((0..9).map(|step| Primitive::Text { |
| 1246 | clip: None, | 1339 | clip: None, |
| ... | @@ -1325,6 +1418,10 @@ mod tests { | ... | @@ -1325,6 +1418,10 @@ mod tests { |
| 1325 | }) | 1418 | }) |
| 1326 | .collect(); | 1419 | .collect(); |
| 1327 | let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; | 1420 | let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; |
| 1421 | assert_eq!(pixel(490, 232), [255, 0, 0, 255]); | ||
| 1422 | assert_eq!(pixel(473, 232), [255, 0, 0, 255]); | ||
| 1423 | assert_eq!(pixel(490, 238), [255; 4]); | ||
| 1424 | assert_eq!(pixel(472, 228), [255; 4]); | ||
| 1328 | assert_eq!(pixel(300, 140), [255, 0, 0, 255]); | 1425 | assert_eq!(pixel(300, 140), [255, 0, 0, 255]); |
| 1329 | assert_eq!(pixel(280, 120), [255; 4]); | 1426 | assert_eq!(pixel(280, 120), [255; 4]); |
| 1330 | assert_eq!(pixel(282, 124), [255; 4]); | 1427 | assert_eq!(pixel(282, 124), [255; 4]); |
crates/canvas/src/gpu/page.rs+120-2| ... | @@ -37,6 +37,50 @@ fn outline_origin( | ... | @@ -37,6 +37,50 @@ fn outline_origin( |
| 37 | Ok(origin) | 37 | Ok(origin) |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec<Primitive<'_>>) { | ||
| 41 | for stroke in &ink.strokes { | ||
| 42 | let mut color = colorref(stroke.color.unwrap_or(0)); | ||
| 43 | color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0; | ||
| 44 | let width = stroke.width.max(stroke.height); | ||
| 45 | let round = stroke.pen_tip != Some(1); | ||
| 46 | let mut points = stroke | ||
| 47 | .points | ||
| 48 | .iter() | ||
| 49 | .map(|[x, y]| [x + offset[0], y + offset[1]]); | ||
| 50 | let Some(mut from) = points.next() else { | ||
| 51 | continue; | ||
| 52 | }; | ||
| 53 | let mut drawn = false; | ||
| 54 | for to in points { | ||
| 55 | // Pen samples far closer than a pixel add vertices without changing the stroke. | ||
| 56 | if (to[0] - from[0]).hypot(to[1] - from[1]) < 0.2 { | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | primitives.push(Primitive::Segment { | ||
| 60 | from, | ||
| 61 | to, | ||
| 62 | width, | ||
| 63 | round, | ||
| 64 | color, | ||
| 65 | }); | ||
| 66 | from = to; | ||
| 67 | drawn = true; | ||
| 68 | } | ||
| 69 | if !drawn { | ||
| 70 | primitives.push(Primitive::Segment { | ||
| 71 | from, | ||
| 72 | to: from, | ||
| 73 | width, | ||
| 74 | round, | ||
| 75 | color, | ||
| 76 | }); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | for group in &ink.groups { | ||
| 80 | append_ink(group, offset, primitives); | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 40 | #[derive(Debug)] | 84 | #[derive(Debug)] |
| 41 | pub enum SceneError { | 85 | pub enum SceneError { |
| 42 | Layout(LayoutError), | 86 | Layout(LayoutError), |
| ... | @@ -204,6 +248,7 @@ impl PageScene { | ... | @@ -204,6 +248,7 @@ impl PageScene { |
| 204 | source.layout.x.unwrap_or(0.0) + source.layout.max_width?, | 248 | source.layout.x.unwrap_or(0.0) + source.layout.max_width?, |
| 205 | source.layout.y.unwrap_or(0.0) + source.layout.max_height?, | 249 | source.layout.y.unwrap_or(0.0) + source.layout.max_height?, |
| 206 | ]), | 250 | ]), |
| 251 | Content::Ink(ink) => crate::editor::page::ink_bounds(ink), | ||
| 207 | Content::ReadOnly(object) => Some(object.rect()), | 252 | Content::ReadOnly(object) => Some(object.rect()), |
| 208 | Content::Editable(_) => None, | 253 | Content::Editable(_) => None, |
| 209 | }) | 254 | }) |
| ... | @@ -297,7 +342,7 @@ impl PageScene { | ... | @@ -297,7 +342,7 @@ impl PageScene { |
| 297 | return Some(SceneHit::Image(source.id)); | 342 | return Some(SceneHit::Image(source.id)); |
| 298 | } | 343 | } |
| 299 | } | 344 | } |
| 300 | Content::Outline { .. } | Content::Image(_) => {} | 345 | Content::Outline { .. } | Content::Image(_) | Content::Ink(_) => {} |
| 301 | } | 346 | } |
| 302 | } | 347 | } |
| 303 | None | 348 | None |
| ... | @@ -381,6 +426,10 @@ impl PageScene { | ... | @@ -381,6 +426,10 @@ impl PageScene { |
| 381 | outline(*id, offset, primitives)?; | 426 | outline(*id, offset, primitives)?; |
| 382 | continue; | 427 | continue; |
| 383 | } | 428 | } |
| 429 | Content::Ink(ink) => { | ||
| 430 | append_ink(ink, offset, primitives); | ||
| 431 | continue; | ||
| 432 | } | ||
| 384 | }; | 433 | }; |
| 385 | let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; | 434 | let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; |
| 386 | match content { | 435 | match content { |
| ... | @@ -453,7 +502,7 @@ impl PageScene { | ... | @@ -453,7 +502,7 @@ impl PageScene { |
| 453 | } | 502 | } |
| 454 | } | 503 | } |
| 455 | } | 504 | } |
| 456 | Content::Editable(_) | Content::ReadOnly(_) => unreachable!(), | 505 | Content::Editable(_) | Content::ReadOnly(_) | Content::Ink(_) => unreachable!(), |
| 457 | } | 506 | } |
| 458 | } | 507 | } |
| 459 | Ok(()) | 508 | Ok(()) |
| ... | @@ -1522,6 +1571,75 @@ mod tests { | ... | @@ -1522,6 +1571,75 @@ mod tests { |
| 1522 | )); | 1571 | )); |
| 1523 | } | 1572 | } |
| 1524 | 1573 | ||
| 1574 | #[test] | ||
| 1575 | fn ink_draws_every_stroke_and_bounds_include_the_pen() { | ||
| 1576 | use onestore::page::{Ink, InkStroke}; | ||
| 1577 | let stroke = |points: Vec<[f32; 2]>, color| InkStroke { | ||
| 1578 | id: ExGuid::default(), | ||
| 1579 | points, | ||
| 1580 | width: 2.0, | ||
| 1581 | height: 2.0, | ||
| 1582 | color, | ||
| 1583 | transparency: Some(51), | ||
| 1584 | pen_tip: None, | ||
| 1585 | }; | ||
| 1586 | let ink = Ink { | ||
| 1587 | id: ExGuid::default(), | ||
| 1588 | layout: Layout::default(), | ||
| 1589 | // A straight vertical line has no width of its own. | ||
| 1590 | strokes: vec![stroke(vec![[10.0, 20.0], [10.0, 20.1], [10.0, 60.0]], None)], | ||
| 1591 | groups: vec![Ink { | ||
| 1592 | id: ExGuid::default(), | ||
| 1593 | layout: Layout::default(), | ||
| 1594 | strokes: vec![stroke(vec![[40.0, 30.0]], Some(0x0000ff))], | ||
| 1595 | groups: Vec::new(), | ||
| 1596 | }], | ||
| 1597 | }; | ||
| 1598 | let page = Page { | ||
| 1599 | identity: None, | ||
| 1600 | created: None, | ||
| 1601 | title: String::new(), | ||
| 1602 | margin_origin: [0.0; 2], | ||
| 1603 | definitions: BTreeMap::new(), | ||
| 1604 | objects: vec![PageObject::Ink(ink.clone())], | ||
| 1605 | }; | ||
| 1606 | let mut engine = TextEngine::default(); | ||
| 1607 | let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); | ||
| 1608 | assert!( | ||
| 1609 | scene | ||
| 1610 | .content_bounds(&editor) | ||
| 1611 | .any(|bounds| bounds == [9.0, 19.0, 41.0, 61.0]) | ||
| 1612 | ); | ||
| 1613 | let mut primitives = Vec::new(); | ||
| 1614 | scene | ||
| 1615 | .append_primitives_with::<SceneError>( | ||
| 1616 | &mut primitives, | ||
| 1617 | [5.0, 0.0], | ||
| 1618 | Some(&editor), | ||
| 1619 | None, | ||
| 1620 | |_, _, _| Ok(()), | ||
| 1621 | ) | ||
| 1622 | .unwrap(); | ||
| 1623 | let segments: Vec<_> = primitives | ||
| 1624 | .iter() | ||
| 1625 | .filter_map(|primitive| match primitive { | ||
| 1626 | Primitive::Segment { | ||
| 1627 | from, to, color, .. | ||
| 1628 | } => Some((*from, *to, *color)), | ||
| 1629 | _ => None, | ||
| 1630 | }) | ||
| 1631 | .collect(); | ||
| 1632 | let red = colorref(0x0000ff); | ||
| 1633 | assert_eq!( | ||
| 1634 | segments, | ||
| 1635 | [ | ||
| 1636 | ([15.0, 20.0], [15.0, 60.0], [0.0, 0.0, 0.0, 0.8]), | ||
| 1637 | ([45.0, 30.0], [45.0, 30.0], [red[0], red[1], red[2], 0.8]), | ||
| 1638 | ] | ||
| 1639 | ); | ||
| 1640 | assert_eq!(editor.page().unwrap().objects, [PageObject::Ink(ink)]); | ||
| 1641 | } | ||
| 1642 | |||
| 1525 | #[test] | 1643 | #[test] |
| 1526 | fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { | 1644 | fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { |
| 1527 | let mut encoded = Vec::new(); | 1645 | let mut encoded = Vec::new(); |
tools/canvas/README.md+2| ... | @@ -131,6 +131,8 @@ Unsupported top-level objects, outlines or titles render as read-only placeholde | ... | @@ -131,6 +131,8 @@ Unsupported top-level objects, outlines or titles render as read-only placeholde |
| 131 | 131 | ||
| 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. | 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. |
| 133 | 133 | ||
| 134 | 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. | ||
| 135 | |||
| 134 | 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. | 136 | 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. |
| 135 | 137 | ||
| 136 | ## Owned page import | 138 | ## Owned page import |