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 {...@@ -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};
7use onestore::page::text::Paragraph;7use onestore::page::text::Paragraph;
8use onestore::page::{Image, Outline, Page, PageObject};8use onestore::page::{Image, Ink, Outline, Page, PageObject};
9use std::collections::BTreeMap;9use std::collections::BTreeMap;
1010
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.
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> {...@@ -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}
221230
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 }
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
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}
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
40#[derive(Debug)]84#[derive(Debug)]
41pub enum SceneError {85pub 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 None348 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 }
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
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
131131
132Placeholders 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.132Placeholders 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
134Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction.136Document 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
136## Owned page import138## Owned page import