authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 02:18:45-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log9954b152f54c18a0150bbf7ef92fc56b4c23d2a8
treee8a53a68e43fb5fb38c5b4b6c7be88a3fa317893
parent2632a55ddb21bfc41f307e6d85abff189f9823dd
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: draw page ink as OneNote does

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.5

5 files changed, 264 insertions(+), 19 deletions(-)

crates/canvas/src/editor.rs+5
......@@ -686,6 +686,7 @@ impl CanvasEditor {
686686 .iter()
687687 .filter_map(|object| match object {
688688 page::Content::ReadOnly(object) => Some(object.rect()[2]),
689 page::Content::Ink(ink) => page::ink_bounds(ink).map(|b| b[2]),
689690 _ => None,
690691 })
691692 .fold(0.0_f32, f32::max);
......@@ -740,6 +741,10 @@ impl CanvasEditor {
740741 objects.push(PageObject::Image(image.clone()));
741742 continue;
742743 }
744 page::Content::Ink(ink) => {
745 objects.push(PageObject::Ink(ink.clone()));
746 continue;
747 }
743748 page::Content::ReadOnly(object) => {
744749 objects.push(object.source.clone());
745750 continue;
crates/canvas/src/editor/page.rs+40-17
......@@ -5,7 +5,7 @@ use crate::{
55 outline::{Arrange, OutlineLayout},
66};
77use onestore::page::text::Paragraph;
8use onestore::page::{Image, Outline, Page, PageObject};
8use onestore::page::{Image, Ink, Outline, Page, PageObject};
99use std::collections::BTreeMap;
1010
1111/// A title object's own state, plus the child origins `build` replaces with page coordinates.
......@@ -33,6 +33,7 @@ pub(crate) enum Content {
3333 below_title: Option<onestore::ExGuid>,
3434 },
3535 Image(Image),
36 Ink(Ink),
3637 Editable(onestore::ExGuid),
3738 ReadOnly(Box<ReadOnlyObject>),
3839}
......@@ -50,6 +51,7 @@ impl Content {
5051 match self {
5152 Self::Outline { source, .. } => Some((source.id, &source.layout)),
5253 Self::Image(source) => Some((source.id, &source.layout)),
54 Self::Ink(source) => Some((source.id, &source.layout)),
5355 Self::ReadOnly(object) => Some((object.source.id(), object.source.layout())),
5456 Self::Date { .. } | Self::Editable(_) => None,
5557 }
......@@ -62,7 +64,8 @@ impl Content {
6264 Self::Outline { source, .. } => Some((source.id, &mut source.layout)),
6365 Self::Image(source) => Some((source.id, &mut source.layout)),
6466 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,
6669 }
6770 }
6871}
......@@ -80,15 +83,10 @@ impl ReadOnlyObject {
8083 [0.0; 2]
8184 };
8285 let layout = source.layout();
83 // Ink carries no layout of its own; its strokes say where it is.
84 let extent = match &source {
85 PageObject::Ink(ink) => ink.bounds(),
86 _ => None,
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]);
86 let x = layout.x.unwrap_or(0.0) + offset[0];
87 let y = layout.y.unwrap_or(0.0) + offset[1];
88 let width = layout.max_width.unwrap_or(160.0);
89 let height = layout.max_height.unwrap_or(42.0);
9290 if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 {
9391 return Err(EditorError::InvalidGeometry);
9492 }
......@@ -326,12 +324,15 @@ pub(crate) fn build(
326324 };
327325 objects.push(Content::Image(source));
328326 }
329 PageObject::Ink(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new(
330 object,
331 page.margin_origin,
332 "Ink\nRead-only",
333 engine,
334 )?)),
327 PageObject::Ink(ink) => {
328 if ink_bounds(ink).is_some_and(|b| b.iter().any(|v| !v.is_finite())) {
329 return Err(EditorError::InvalidGeometry);
330 }
331 let PageObject::Ink(ink) = object else {
332 unreachable!()
333 };
334 objects.push(Content::Ink(ink))
335 }
335336 PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new(
336337 object,
337338 page.margin_origin,
......@@ -347,3 +348,25 @@ pub(crate) fn build(
347348 areas,
348349 })
349350}
351
352/// The painted extent of every stroke, pen included, as `[x0, y0, x1, y1]` page points.
353pub(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> {
217217 image: &'a RasterImage,
218218 rect: [f32; 4],
219219 },
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 },
220229}
221230
222231#[derive(Debug)]
......@@ -578,6 +587,13 @@ impl Renderer {
578587 vertex.stroke = width;
579588 }
580589 }
590 Primitive::Segment {
591 from,
592 to,
593 width,
594 round,
595 color,
596 } => self.segment(viewport, *from, *to, *width, *round, *color)?,
581597 Primitive::Image { image, rect } => {
582598 if let Some(rect) = viewport.visible_image_rect(*rect)? {
583599 self.image(image, active_images)?;
......@@ -943,6 +959,76 @@ impl Renderer {
943959 )
944960 }
945961
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
9461032 fn quad(
9471033 &mut self,
9481034 viewport: Viewport,
......@@ -1241,6 +1327,13 @@ mod tests {
12411327 tag: &tags[3],
12421328 origin: [188.125, 80.25],
12431329 },
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 },
12441337 ];
12451338 primitives.extend((0..9).map(|step| Primitive::Text {
12461339 clip: None,
......@@ -1325,6 +1418,10 @@ mod tests {
13251418 })
13261419 .collect();
13271420 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]);
13281425 assert_eq!(pixel(300, 140), [255, 0, 0, 255]);
13291426 assert_eq!(pixel(280, 120), [255; 4]);
13301427 assert_eq!(pixel(282, 124), [255; 4]);
crates/canvas/src/gpu/page.rs+120-2
......@@ -37,6 +37,50 @@ fn outline_origin(
3737 Ok(origin)
3838}
3939
40fn 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
4084#[derive(Debug)]
4185pub enum SceneError {
4286 Layout(LayoutError),
......@@ -204,6 +248,7 @@ impl PageScene {
204248 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,
205249 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,
206250 ]),
251 Content::Ink(ink) => crate::editor::page::ink_bounds(ink),
207252 Content::ReadOnly(object) => Some(object.rect()),
208253 Content::Editable(_) => None,
209254 })
......@@ -297,7 +342,7 @@ impl PageScene {
297342 return Some(SceneHit::Image(source.id));
298343 }
299344 }
300 Content::Outline { .. } | Content::Image(_) => {}
345 Content::Outline { .. } | Content::Image(_) | Content::Ink(_) => {}
301346 }
302347 }
303348 None
......@@ -381,6 +426,10 @@ impl PageScene {
381426 outline(*id, offset, primitives)?;
382427 continue;
383428 }
429 Content::Ink(ink) => {
430 append_ink(ink, offset, primitives);
431 continue;
432 }
384433 };
385434 let object_origin = [origin[0] + offset[0], origin[1] + offset[1]];
386435 match content {
......@@ -453,7 +502,7 @@ impl PageScene {
453502 }
454503 }
455504 }
456 Content::Editable(_) | Content::ReadOnly(_) => unreachable!(),
505 Content::Editable(_) | Content::ReadOnly(_) | Content::Ink(_) => unreachable!(),
457506 }
458507 }
459508 Ok(())
......@@ -1522,6 +1571,75 @@ mod tests {
15221571 ));
15231572 }
15241573
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
15251643 #[test]
15261644 fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() {
15271645 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
131131
132132Placeholders 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.
133133
134Page-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
134136Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction.
135137
136138## Owned page import