| 1 | mod background; |
| 2 | mod picture; |
| 3 | |
| 4 | use super::{Paper, colorref}; |
| 5 | use crate::editor::ReadOnlyObject; |
| 6 | use crate::editor::page::Content; |
| 7 | use crate::{ |
| 8 | date::DateField, |
| 9 | editor::{CanvasEditor, EditorError}, |
| 10 | layout::{LayoutError, TextEngine}, |
| 11 | }; |
| 12 | use background::Background; |
| 13 | pub use background::{template_art, template_picture}; |
| 14 | use draw::Primitive; |
| 15 | use onestore::page::Page; |
| 16 | use picture::Picture; |
| 17 | use std::{ |
| 18 | fmt, |
| 19 | sync::{Arc, Mutex}, |
| 20 | task::Waker, |
| 21 | time::Duration, |
| 22 | }; |
| 23 | use web_time::Instant; |
| 24 | |
| 25 | /// Retained drawing data in the source page's coordinate system. |
| 26 | pub struct PageScene { |
| 27 | reference: Option<Vec<Content>>, |
| 28 | /// The definitions a page read without an editor names, as its objects' tags do. |
| 29 | definitions: std::collections::BTreeMap<onestore::ExGuid, onestore::page::Definition>, |
| 30 | /// Pictures and file icons, by the identity of the object that shows them. |
| 31 | pictures: std::collections::BTreeMap<onestore::ExGuid, Picture>, |
| 32 | /// Background pictures from OneNote's page templates, painted from their recreations. |
| 33 | backgrounds: std::collections::BTreeMap<onestore::ExGuid, Background>, |
| 34 | } |
| 35 | |
| 36 | /// How long the zoom must hold before a raster is made again for it. |
| 37 | const SETTLE: Duration = Duration::from_millis(150); |
| 38 | |
| 39 | /// Where work done off the frame thread lands while its requester keeps it. |
| 40 | type Slot<T> = Arc<Mutex<Option<T>>>; |
| 41 | |
| 42 | /// Runs `work` on the scenes' one raster thread once `start` has passed, unless the slot |
| 43 | /// returned has been dropped by then; the result lands in the slot and wakes `waker`. |
| 44 | #[cfg(not(target_arch = "wasm32"))] |
| 45 | fn queue<T: Send + 'static>( |
| 46 | start: Instant, |
| 47 | waker: &Waker, |
| 48 | work: impl FnOnce() -> T + Send + 'static, |
| 49 | ) -> Slot<T> { |
| 50 | use std::sync::{OnceLock, mpsc}; |
| 51 | static WORKER: OnceLock<mpsc::Sender<Box<dyn FnOnce() + Send>>> = OnceLock::new(); |
| 52 | let slot = Slot::default(); |
| 53 | let requester = Arc::downgrade(&slot); |
| 54 | let waker = waker.clone(); |
| 55 | WORKER |
| 56 | .get_or_init(|| { |
| 57 | let (sender, jobs) = mpsc::channel::<Box<dyn FnOnce() + Send>>(); |
| 58 | std::thread::Builder::new() |
| 59 | .name("page pictures".into()) |
| 60 | .spawn(move || jobs.into_iter().for_each(|job| job())) |
| 61 | .expect("The picture thread starts"); |
| 62 | sender |
| 63 | }) |
| 64 | .send(Box::new(move || { |
| 65 | std::thread::sleep(start.saturating_duration_since(Instant::now())); |
| 66 | if let Some(slot) = requester.upgrade() { |
| 67 | *slot.lock().unwrap() = Some(work()); |
| 68 | waker.wake(); |
| 69 | } |
| 70 | })) |
| 71 | .expect("The picture thread outlives its jobs"); |
| 72 | slot |
| 73 | } |
| 74 | |
| 75 | /// `queue` without threads, as in the browser: `work` runs now. |
| 76 | #[cfg(target_arch = "wasm32")] |
| 77 | fn queue<T: Send + 'static>( |
| 78 | _: Instant, |
| 79 | waker: &Waker, |
| 80 | work: impl FnOnce() -> T + Send + 'static, |
| 81 | ) -> Slot<T> { |
| 82 | waker.wake_by_ref(); |
| 83 | Arc::new(Mutex::new(Some(work()))) |
| 84 | } |
| 85 | |
| 86 | /// Device pixels per point a raster is made at for `scale`: the next half power of two, |
| 87 | /// so zooming makes rasters again in steps. |
| 88 | fn density(scale: f32) -> f32 { |
| 89 | 2_f32.powf((scale.max(0.25).log2() * 2.0).ceil() / 2.0) |
| 90 | } |
| 91 | |
| 92 | #[derive(Debug, PartialEq)] |
| 93 | pub enum SceneHit<T> { |
| 94 | Outline(T), |
| 95 | Date(DateField), |
| 96 | ReadOnly(usize), |
| 97 | Image(onestore::ExGuid), |
| 98 | File(onestore::ExGuid), |
| 99 | /// A check box tag on a picture or file on the page. |
| 100 | Check(onestore::ExGuid), |
| 101 | } |
| 102 | |
| 103 | fn outline_origin( |
| 104 | mut origin: [f32; 2], |
| 105 | title: Option<onestore::ExGuid>, |
| 106 | editor: Option<&CanvasEditor>, |
| 107 | column: Option<&crate::reading::Column>, |
| 108 | ) -> Result<[f32; 2], SceneError> { |
| 109 | if let Some(id) = title { |
| 110 | let title = editor |
| 111 | .and_then(|editor| editor.visible_outlines().find(|outline| outline.id == id)) |
| 112 | .ok_or(SceneError::MissingOutline)?; |
| 113 | let (geometry, projected) = crate::reading::geometry(title, column); |
| 114 | origin[1] += if column.is_some() { |
| 115 | projected[1] + geometry.shaped.size[1] |
| 116 | } else { |
| 117 | title.bounds().y1 as f32 |
| 118 | }; |
| 119 | } |
| 120 | Ok(origin) |
| 121 | } |
| 122 | |
| 123 | /// Draws `ink` moved by `offset`, strokes without a colour in the paper's `automatic` ink. A |
| 124 | /// highlighter's rectangular tip sweeps a band as wide as the tip reaches across each |
| 125 | /// segment, its colour multiplying what lies beneath as OneNote's MaskPen does. A pressure |
| 126 | /// pen's width follows its pressure from point to point, as OneNote's does. |
| 127 | pub(crate) fn append_ink( |
| 128 | ink: &onestore::page::Ink, |
| 129 | offset: [f32; 2], |
| 130 | automatic: [f32; 4], |
| 131 | primitives: &mut Vec<Primitive<'_>>, |
| 132 | ) { |
| 133 | for stroke in &ink.strokes { |
| 134 | let highlighter = stroke.raster_operation == Some(9); |
| 135 | let mut color = stroke.color.map_or(automatic, colorref); |
| 136 | if !highlighter { |
| 137 | color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0; |
| 138 | } |
| 139 | let round = stroke.pen_tip != Some(1); |
| 140 | let size = stroke.width.max(stroke.height); |
| 141 | let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| { |
| 142 | if !highlighter { |
| 143 | if t0 != t1 { |
| 144 | return Primitive::Taper { |
| 145 | from, |
| 146 | to, |
| 147 | widths: [size * t0, size * t1], |
| 148 | round, |
| 149 | color, |
| 150 | }; |
| 151 | } |
| 152 | return Primitive::Segment { |
| 153 | from, |
| 154 | to, |
| 155 | width: size * (t0 + t1) / 2.0, |
| 156 | round, |
| 157 | color, |
| 158 | }; |
| 159 | } |
| 160 | let [dx, dy] = [to[0] - from[0], to[1] - from[1]]; |
| 161 | let length = dx.hypot(dy); |
| 162 | let [along, across] = if length > 0.0 { |
| 163 | [dx.abs() / length, dy.abs() / length] |
| 164 | } else { |
| 165 | [1.0, 0.0] |
| 166 | }; |
| 167 | Primitive::Highlight { |
| 168 | from, |
| 169 | to, |
| 170 | width: (stroke.width * across + stroke.height * along) * (t0 + t1) / 2.0, |
| 171 | color, |
| 172 | } |
| 173 | }; |
| 174 | let mut points = stroke |
| 175 | .points |
| 176 | .iter() |
| 177 | .enumerate() |
| 178 | .map(|(index, [x, y])| ([x + offset[0], y + offset[1]], stroke.thickness(index))); |
| 179 | let Some((mut from, mut thick)) = points.next() else { |
| 180 | continue; |
| 181 | }; |
| 182 | // Samples along a straight run of one width make one segment, since each joint paints |
| 183 | // its overlap twice. |
| 184 | let (mut end, mut end_thick) = (from, thick); |
| 185 | for (to, to_thick) in points { |
| 186 | let same = end == from || (to_thick == end_thick && end_thick == thick); |
| 187 | if !(same && between(from, end, to)) { |
| 188 | primitives.push(segment(from, end, [thick, end_thick])); |
| 189 | (from, thick) = (end, end_thick); |
| 190 | } |
| 191 | (end, end_thick) = (to, to_thick); |
| 192 | } |
| 193 | primitives.push(segment(from, end, [thick, end_thick])); |
| 194 | } |
| 195 | for group in &ink.groups { |
| 196 | append_ink(group, offset, automatic, primitives); |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | /// Whether `middle` lies within a tenth of a point of the segment from `from` to `to`. |
| 201 | fn between(from: [f32; 2], middle: [f32; 2], to: [f32; 2]) -> bool { |
| 202 | let [dx, dy] = [to[0] - from[0], to[1] - from[1]]; |
| 203 | let [mx, my] = [middle[0] - from[0], middle[1] - from[1]]; |
| 204 | let length = dx * dx + dy * dy; |
| 205 | if length == 0.0 { |
| 206 | return mx.hypot(my) <= 0.1; |
| 207 | } |
| 208 | let along = (mx * dx + my * dy) / length; |
| 209 | (0.0..=1.0).contains(&along) && (mx * dy - my * dx).abs() / length.sqrt() <= 0.1 |
| 210 | } |
| 211 | |
| 212 | /// The size OneNote 2010 shows a file's icon at, in points. |
| 213 | pub const ICON_SIZE: [f32; 2] = [24.0, 24.0]; |
| 214 | |
| 215 | const FILE_ICON: &str = include_str!("../../assets/file.svg"); |
| 216 | |
| 217 | /// The blank page for a file without an icon of its own, as the 32 pixel PNG OneNote 2010 |
| 218 | /// stores with every attachment; it draws a broken picture for one stored without. |
| 219 | pub fn file_icon() -> Vec<u8> { |
| 220 | background::png(FILE_ICON, 32) |
| 221 | } |
| 222 | |
| 223 | fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primitive<'_>>) { |
| 224 | primitives.push(Primitive::Rect { |
| 225 | rect, |
| 226 | color: paper.shade(colorref(0x00e4ddd6)), |
| 227 | }); |
| 228 | let inner = [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0]; |
| 229 | if inner[0] < inner[2] && inner[1] < inner[3] { |
| 230 | primitives.push(Primitive::Rect { |
| 231 | rect: inner, |
| 232 | color: paper.shade(colorref(0x00faf7f3)), |
| 233 | }); |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | #[derive(Debug)] |
| 238 | pub enum SceneError { |
| 239 | Layout(LayoutError), |
| 240 | Editor(EditorError), |
| 241 | MissingOutline, |
| 242 | MissingImage, |
| 243 | InvalidGeometry, |
| 244 | } |
| 245 | |
| 246 | impl fmt::Display for SceneError { |
| 247 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 248 | match self { |
| 249 | Self::Layout(error) => error.fmt(f), |
| 250 | Self::Editor(error) => error.fmt(f), |
| 251 | Self::MissingOutline => f.write_str("An editable outline is missing from this page."), |
| 252 | Self::MissingImage => f.write_str("An image is missing its data or dimensions."), |
| 253 | Self::InvalidGeometry => { |
| 254 | f.write_str("The page contains invalid object dimensions or positions.") |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | impl std::error::Error for SceneError {} |
| 260 | impl From<LayoutError> for SceneError { |
| 261 | fn from(error: LayoutError) -> Self { |
| 262 | Self::Layout(error) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | impl From<EditorError> for SceneError { |
| 267 | fn from(error: EditorError) -> Self { |
| 268 | match error { |
| 269 | EditorError::InvalidGeometry => Self::InvalidGeometry, |
| 270 | error => Self::Editor(error), |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | impl PageScene { |
| 276 | pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> { |
| 277 | let mut objects = crate::editor::page::build(&mut page, engine, false) |
| 278 | .map_err(SceneError::from)? |
| 279 | .objects; |
| 280 | let pictures = Self::pictures(&objects, None)?; |
| 281 | pictures.mark_unavailable(&mut objects, engine)?; |
| 282 | Ok(Self { |
| 283 | reference: Some(objects), |
| 284 | definitions: std::mem::take(&mut page.definitions), |
| 285 | ..pictures |
| 286 | }) |
| 287 | } |
| 288 | |
| 289 | pub fn from_page( |
| 290 | page: Page, |
| 291 | engine: &mut TextEngine, |
| 292 | ) -> Result<(Self, CanvasEditor), SceneError> { |
| 293 | let mut editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; |
| 294 | let scene = Self::pictures(&editor.objects, Some(&editor))?; |
| 295 | scene.mark_unavailable(&mut editor.objects, engine)?; |
| 296 | Ok((scene, editor)) |
| 297 | } |
| 298 | |
| 299 | /// Follows `editor` after it refreshed from storage, keeping the rasters of pictures |
| 300 | /// whose bytes did not change so they do not blink. |
| 301 | pub fn refresh( |
| 302 | &mut self, |
| 303 | editor: &mut CanvasEditor, |
| 304 | engine: &mut TextEngine, |
| 305 | ) -> Result<(), SceneError> { |
| 306 | let mut scene = Self::pictures(&editor.objects, Some(editor))?; |
| 307 | for (id, picture) in &mut scene.pictures { |
| 308 | if let Some(shown) = self.pictures.remove(id) |
| 309 | && shown.same(picture) |
| 310 | { |
| 311 | *picture = shown; |
| 312 | } |
| 313 | } |
| 314 | for (id, art) in &mut scene.backgrounds { |
| 315 | match self.backgrounds.remove(id) { |
| 316 | Some(shown) if shown.same(art) => *art = shown, |
| 317 | _ => art.inline = true, |
| 318 | } |
| 319 | } |
| 320 | scene.mark_unavailable(&mut editor.objects, engine)?; |
| 321 | *self = scene; |
| 322 | Ok(()) |
| 323 | } |
| 324 | |
| 325 | /// Follows pictures and file previews brought by an edit or undo. |
| 326 | pub(crate) fn follow( |
| 327 | &mut self, |
| 328 | editor: &mut CanvasEditor, |
| 329 | engine: &mut TextEngine, |
| 330 | ) -> Result<(), SceneError> { |
| 331 | let known = |object: &Content| match object { |
| 332 | Content::Image(source) => { |
| 333 | self.pictures.contains_key(&source.id) || self.backgrounds.contains_key(&source.id) |
| 334 | } |
| 335 | Content::File { source, .. } => { |
| 336 | source.preview.is_none() || self.pictures.contains_key(&source.id) |
| 337 | } |
| 338 | _ => true, |
| 339 | }; |
| 340 | let outlines_known = editor.outlines().iter().all(|outline| { |
| 341 | crate::document::descendants(outline.document().nodes(), None).all(|(_, _, node)| { |
| 342 | match &node.content { |
| 343 | onestore::page::ParagraphContent::Image(image) => { |
| 344 | self.pictures.contains_key(&image.id) |
| 345 | } |
| 346 | onestore::page::ParagraphContent::Attachment(file) => { |
| 347 | file.preview.is_none() || self.pictures.contains_key(&file.id) |
| 348 | } |
| 349 | _ => true, |
| 350 | } |
| 351 | }) |
| 352 | }); |
| 353 | if editor.objects.iter().all(known) && outlines_known { |
| 354 | return Ok(()); |
| 355 | } |
| 356 | self.refresh(editor, engine) |
| 357 | } |
| 358 | |
| 359 | /// Turns page pictures the renderer cannot decode into placeholders; their stored data |
| 360 | /// is kept. |
| 361 | fn mark_unavailable( |
| 362 | &self, |
| 363 | objects: &mut Vec<Content>, |
| 364 | engine: &mut TextEngine, |
| 365 | ) -> Result<(), SceneError> { |
| 366 | *objects = std::mem::take(objects) |
| 367 | .into_iter() |
| 368 | .map(|object| match object { |
| 369 | Content::Image(source) |
| 370 | if !self.pictures.contains_key(&source.id) |
| 371 | && !self.backgrounds.contains_key(&source.id) => |
| 372 | { |
| 373 | Content::unavailable(onestore::page::PageObject::Image(source), engine) |
| 374 | } |
| 375 | object => Ok(object), |
| 376 | }) |
| 377 | .collect::<Result<_, _>>()?; |
| 378 | Ok(()) |
| 379 | } |
| 380 | |
| 381 | /// Every picture the page draws, page-level ones and those inside outlines, except the |
| 382 | /// template backgrounds it recognises; one the renderer cannot decode is left out. A |
| 383 | /// picture shows its stored bytes, else the raster OneNote made of them. |
| 384 | fn pictures(objects: &[Content], editor: Option<&CanvasEditor>) -> Result<Self, SceneError> { |
| 385 | type Payloads<'a> = Vec<(onestore::ExGuid, [Option<&'a Arc<[u8]>>; 2])>; |
| 386 | fn nested<'a>(nodes: &'a [onestore::page::PageParagraph], payloads: &mut Payloads<'a>) { |
| 387 | for node in nodes { |
| 388 | match &node.content { |
| 389 | onestore::page::ParagraphContent::Image(image) => { |
| 390 | payloads.push((image.id, [image.bytes.as_ref(), image.display.as_ref()])) |
| 391 | } |
| 392 | onestore::page::ParagraphContent::Attachment(file) => { |
| 393 | payloads.push((file.id, [file.preview.as_ref(), None])) |
| 394 | } |
| 395 | onestore::page::ParagraphContent::Table(table) => { |
| 396 | for cell in table.rows.iter().flat_map(|row| &row.cells) { |
| 397 | nested(&cell.paragraphs, payloads); |
| 398 | } |
| 399 | } |
| 400 | _ => {} |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | let mut payloads = Vec::new(); |
| 405 | let mut backgrounds = std::collections::BTreeMap::new(); |
| 406 | for object in objects { |
| 407 | match object { |
| 408 | Content::Image(source) => { |
| 409 | if source.background |
| 410 | && let Some(art) = Background::recognise(source) |
| 411 | { |
| 412 | backgrounds.insert(source.id, art); |
| 413 | } else { |
| 414 | payloads.push((source.id, [source.bytes.as_ref(), source.display.as_ref()])) |
| 415 | } |
| 416 | } |
| 417 | Content::File { source, .. } => { |
| 418 | payloads.push((source.id, [source.preview.as_ref(), None])) |
| 419 | } |
| 420 | Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), |
| 421 | Content::Editable(_) |
| 422 | | Content::Date { .. } |
| 423 | | Content::Ink(_) |
| 424 | | Content::ReadOnly(_) => {} |
| 425 | } |
| 426 | } |
| 427 | if let Some(editor) = editor { |
| 428 | for outline in editor.outlines() { |
| 429 | nested(outline.document().nodes(), &mut payloads); |
| 430 | } |
| 431 | } |
| 432 | let mut pictures = std::collections::BTreeMap::new(); |
| 433 | for (id, encoded) in payloads { |
| 434 | let Some(picture) = encoded.into_iter().flatten().find_map(Picture::new) else { |
| 435 | continue; |
| 436 | }; |
| 437 | if pictures.insert(id, picture).is_some() { |
| 438 | return Err(SceneError::InvalidGeometry); |
| 439 | } |
| 440 | } |
| 441 | Ok(Self { |
| 442 | reference: None, |
| 443 | definitions: Default::default(), |
| 444 | pictures, |
| 445 | backgrounds, |
| 446 | }) |
| 447 | } |
| 448 | |
| 449 | /// Brings template backgrounds to `paper` and the pictures near `view`, a rectangle in |
| 450 | /// scene coordinates, to `scale` device pixels per point, rasterizing and decoding off |
| 451 | /// this thread; `waker` is woken when one lands and the page should be drawn again. |
| 452 | /// Call before collecting each frame's primitives. True once everything in view shows |
| 453 | /// its raster for this paper and scale. |
| 454 | pub(crate) fn update_pictures( |
| 455 | &mut self, |
| 456 | editor: Option<&CanvasEditor>, |
| 457 | column: Option<&crate::reading::Column>, |
| 458 | view: [f32; 4], |
| 459 | scale: f32, |
| 460 | paper: Paper, |
| 461 | waker: &Waker, |
| 462 | ) -> bool { |
| 463 | let mut settled = true; |
| 464 | for art in self.backgrounds.values_mut() { |
| 465 | settled &= art.update(paper, scale, waker); |
| 466 | } |
| 467 | let rects = self.picture_rects(editor, column); |
| 468 | picture::update(&mut self.pictures, rects, view, scale, waker) && settled |
| 469 | } |
| 470 | |
| 471 | /// Waits until the whole page shows its rasters for `paper` at `scale`, for drawing it |
| 472 | /// offscreen. |
| 473 | pub fn settle(&mut self, editor: Option<&CanvasEditor>, scale: f32, paper: Paper) { |
| 474 | let everything = [ |
| 475 | f32::NEG_INFINITY, |
| 476 | f32::NEG_INFINITY, |
| 477 | f32::INFINITY, |
| 478 | f32::INFINITY, |
| 479 | ]; |
| 480 | while !self.update_pictures(editor, None, everything, scale, paper, Waker::noop()) { |
| 481 | std::thread::sleep(Duration::from_millis(1)); |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | /// Bytes of the rasters the scene keeps decoded, for bounding what a cache of scenes holds. |
| 486 | pub fn raster_bytes(&self) -> u64 { |
| 487 | let size = |image: &draw::RasterImage| { |
| 488 | let [width, height] = image.size(); |
| 489 | u64::from(width) * u64::from(height) * 4 |
| 490 | }; |
| 491 | let pictures = self.pictures.values().filter_map(Picture::image); |
| 492 | let art = self.backgrounds.values().flat_map(Background::images); |
| 493 | pictures.chain(art).map(size).sum() |
| 494 | } |
| 495 | |
| 496 | /// The raster a picture or file icon shows, once decoded. |
| 497 | pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> { |
| 498 | self.pictures.get(&id)?.image() |
| 499 | } |
| 500 | |
| 501 | /// Where pictures and file icons sit, in scene coordinates. |
| 502 | fn picture_rects( |
| 503 | &self, |
| 504 | editor: Option<&CanvasEditor>, |
| 505 | column: Option<&crate::reading::Column>, |
| 506 | ) -> Vec<(onestore::ExGuid, [f32; 4])> { |
| 507 | fn outline( |
| 508 | layout: &crate::outline::OutlineLayout, |
| 509 | [x, y]: [f32; 2], |
| 510 | rects: &mut Vec<(onestore::ExGuid, [f32; 4])>, |
| 511 | ) { |
| 512 | rects.extend( |
| 513 | layout |
| 514 | .objects |
| 515 | .iter() |
| 516 | .map(|object| (object.id, crate::translated(object.rect, [x, y]))), |
| 517 | ); |
| 518 | } |
| 519 | let mut rects = Vec::new(); |
| 520 | for content in self.objects(editor).into_iter().flatten() { |
| 521 | match content { |
| 522 | Content::Image(source) => match column { |
| 523 | Some(column) => rects.extend(column.picture(source.id).map(|r| (source.id, r))), |
| 524 | None => { |
| 525 | let [x, y] = crate::origin(&source.layout); |
| 526 | if let Some([width, height]) = crate::outline::image_size(source) { |
| 527 | rects.push((source.id, [x, y, x + width, y + height])); |
| 528 | } |
| 529 | } |
| 530 | }, |
| 531 | Content::File { source, layout } => { |
| 532 | let origin = crate::origin(&source.layout); |
| 533 | rects.push((source.id, crate::translated(layout.rect, origin))); |
| 534 | } |
| 535 | Content::Outline { |
| 536 | source, |
| 537 | layout, |
| 538 | below_title, |
| 539 | } => { |
| 540 | let origin = crate::origin(&source.layout); |
| 541 | if let Ok(origin) = outline_origin(origin, *below_title, editor, column) { |
| 542 | outline(layout, origin, &mut rects); |
| 543 | } |
| 544 | } |
| 545 | Content::Editable(_) |
| 546 | | Content::Date { .. } |
| 547 | | Content::Ink(_) |
| 548 | | Content::ReadOnly(_) => {} |
| 549 | } |
| 550 | } |
| 551 | if let Some(editor) = editor { |
| 552 | for editable in editor.visible_outlines() { |
| 553 | let (geometry, origin) = crate::reading::geometry(editable, column); |
| 554 | outline(geometry.shaped, origin, &mut rects); |
| 555 | } |
| 556 | } |
| 557 | rects |
| 558 | } |
| 559 | |
| 560 | /// Pictures, files and handwriting inside an outline whose origin is `origin`. |
| 561 | /// `moving` draws one picture at a previewed rectangle, in the same coordinates as `origin`. |
| 562 | pub(crate) fn append_outline_objects<'a>( |
| 563 | &'a self, |
| 564 | outline: &'a crate::outline::OutlineLayout, |
| 565 | origin: [f32; 2], |
| 566 | moving: Option<(onestore::ExGuid, [f32; 4])>, |
| 567 | paper: Paper, |
| 568 | primitives: &mut Vec<Primitive<'a>>, |
| 569 | ) { |
| 570 | for object in &outline.objects { |
| 571 | self.append_object(object, origin, moving, paper, primitives); |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | /// One picture, file, drawing or placeholder laid out from `origin`; `moving` as for |
| 576 | /// `append_outline_objects`. |
| 577 | fn append_object<'a>( |
| 578 | &'a self, |
| 579 | object: &'a crate::outline::ObjectLayout, |
| 580 | origin: [f32; 2], |
| 581 | moving: Option<(onestore::ExGuid, [f32; 4])>, |
| 582 | paper: Paper, |
| 583 | primitives: &mut Vec<Primitive<'a>>, |
| 584 | ) { |
| 585 | let rect = match moving { |
| 586 | Some((id, rect)) if id == object.id => rect, |
| 587 | _ => crate::translated(object.rect, origin), |
| 588 | }; |
| 589 | let picture = self.pictures.get(&object.id); |
| 590 | if let Some(image) = picture.and_then(Picture::image) { |
| 591 | primitives.push(Primitive::Image { image, rect }); |
| 592 | } |
| 593 | match &object.kind { |
| 594 | crate::outline::ObjectKind::Ink(ink) => { |
| 595 | append_ink(ink, [rect[0], rect[1]], paper.ink, primitives) |
| 596 | } |
| 597 | crate::outline::ObjectKind::Unsupported(_) => { |
| 598 | append_placeholder(rect, paper, primitives) |
| 599 | } |
| 600 | crate::outline::ObjectKind::Picture if picture.is_none_or(Picture::failed) => { |
| 601 | append_placeholder(rect, paper, primitives) |
| 602 | } |
| 603 | // A file stored without the icon OneNote rendered for it shows a blank page. |
| 604 | crate::outline::ObjectKind::File(_) if picture.is_none() => { |
| 605 | primitives.push(Primitive::Icon { |
| 606 | sources: &[FILE_ICON], |
| 607 | origin: [rect[0], rect[1]], |
| 608 | size: rect[2] - rect[0], |
| 609 | tint: [1.0; 4], |
| 610 | palette: draw::Palette::default(), |
| 611 | }) |
| 612 | } |
| 613 | crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} |
| 614 | } |
| 615 | if let Some(label) = object.label() { |
| 616 | primitives.push(Primitive::Text { |
| 617 | text: &label.text, |
| 618 | origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], |
| 619 | clip: None, |
| 620 | ink: paper.ink, |
| 621 | }); |
| 622 | } |
| 623 | } |
| 624 | |
| 625 | fn objects<'a>( |
| 626 | &'a self, |
| 627 | editor: Option<&'a CanvasEditor>, |
| 628 | ) -> Result<&'a [Content], SceneError> { |
| 629 | self.reference |
| 630 | .as_deref() |
| 631 | .or_else(|| editor.map(|editor| editor.objects.as_slice())) |
| 632 | .ok_or(SceneError::MissingOutline) |
| 633 | } |
| 634 | |
| 635 | /// The note tags of the pictures and files on the page, each with the object it marks, |
| 636 | /// in page points; `moving` shows one at a previewed rectangle. |
| 637 | fn object_tags( |
| 638 | &self, |
| 639 | editor: Option<&CanvasEditor>, |
| 640 | column: Option<&crate::reading::Column>, |
| 641 | moving: Option<(onestore::ExGuid, [f32; 4])>, |
| 642 | ) -> Vec<(onestore::ExGuid, crate::outline::ParagraphTag)> { |
| 643 | let definitions = match (&self.reference, editor) { |
| 644 | (None, Some(editor)) => &editor.definitions, |
| 645 | _ => &self.definitions, |
| 646 | }; |
| 647 | let Ok(objects) = self.objects(editor) else { |
| 648 | return Vec::new(); |
| 649 | }; |
| 650 | objects |
| 651 | .iter() |
| 652 | .filter_map(|object| { |
| 653 | let (id, tags, bounds) = object.tagged()?; |
| 654 | let bounds = match (column, moving) { |
| 655 | (Some(column), _) => column.picture(id)?, |
| 656 | (None, Some((moved, rect))) if moved == id => rect, |
| 657 | _ => bounds, |
| 658 | }; |
| 659 | Some( |
| 660 | crate::outline::object_tags(tags, definitions, bounds) |
| 661 | .into_iter() |
| 662 | .map(move |tag| (id, tag)), |
| 663 | ) |
| 664 | }) |
| 665 | .flatten() |
| 666 | .collect() |
| 667 | } |
| 668 | |
| 669 | pub fn read_only<'a>( |
| 670 | &'a self, |
| 671 | editor: Option<&'a CanvasEditor>, |
| 672 | ) -> impl Iterator<Item = &'a ReadOnlyObject> { |
| 673 | self.objects(editor) |
| 674 | .into_iter() |
| 675 | .flatten() |
| 676 | .filter_map(|object| match object { |
| 677 | Content::ReadOnly(object) => Some(object.as_ref()), |
| 678 | _ => None, |
| 679 | }) |
| 680 | } |
| 681 | |
| 682 | /// Bounds of noneditable content in page coordinates, each with whether OneNote's view |
| 683 | /// leaves room beyond it: a picture's corner is as far as the view reaches. |
| 684 | pub(crate) fn content_bounds<'a>( |
| 685 | &'a self, |
| 686 | editor: &'a CanvasEditor, |
| 687 | column: Option<&'a crate::reading::Column>, |
| 688 | ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a { |
| 689 | self.objects(Some(editor)) |
| 690 | .into_iter() |
| 691 | .flatten() |
| 692 | .filter_map(move |object| match object { |
| 693 | Content::Outline { |
| 694 | source, |
| 695 | layout, |
| 696 | below_title, |
| 697 | } => { |
| 698 | let origin = outline_origin( |
| 699 | crate::origin(&source.layout), |
| 700 | *below_title, |
| 701 | Some(editor), |
| 702 | column, |
| 703 | ) |
| 704 | .ok()?; |
| 705 | Some(( |
| 706 | [ |
| 707 | origin[0], |
| 708 | origin[1], |
| 709 | origin[0] + layout.size[0], |
| 710 | origin[1] + layout.size[1], |
| 711 | ], |
| 712 | true, |
| 713 | )) |
| 714 | } |
| 715 | Content::Date { below_title } => { |
| 716 | let date = editor.date()?; |
| 717 | let origin = outline_origin( |
| 718 | crate::origin(&date.source().layout), |
| 719 | *below_title, |
| 720 | Some(editor), |
| 721 | column, |
| 722 | ) |
| 723 | .ok()?; |
| 724 | let size = date.layout().size; |
| 725 | Some(( |
| 726 | [ |
| 727 | origin[0], |
| 728 | origin[1], |
| 729 | origin[0] + size[0], |
| 730 | origin[1] + size[1], |
| 731 | ], |
| 732 | true, |
| 733 | )) |
| 734 | } |
| 735 | Content::Image(source) => match column { |
| 736 | Some(column) => Some((column.picture(source.id)?, false)), |
| 737 | None => { |
| 738 | let [x, y] = crate::origin(&source.layout); |
| 739 | let [width, height] = crate::outline::image_size(source)?; |
| 740 | Some(([x, y, x + width, y + height], false)) |
| 741 | } |
| 742 | }, |
| 743 | Content::File { .. } => Some((object.file()?.1, true)), |
| 744 | Content::Ink(_) if column.is_some() => None, |
| 745 | Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)), |
| 746 | Content::ReadOnly(object) => Some((object.rect(), true)), |
| 747 | Content::Editable(_) => None, |
| 748 | }) |
| 749 | .chain( |
| 750 | column |
| 751 | .and_then(crate::reading::Column::drawings_rect) |
| 752 | .map(|rect| (rect, true)), |
| 753 | ) |
| 754 | } |
| 755 | |
| 756 | pub(crate) fn header_text<'a>( |
| 757 | &'a self, |
| 758 | editor: &'a CanvasEditor, |
| 759 | column: &'a crate::reading::Column, |
| 760 | ) -> impl Iterator<Item = (String, [f32; 4])> + 'a { |
| 761 | self.objects(Some(editor)) |
| 762 | .into_iter() |
| 763 | .flatten() |
| 764 | .filter_map(move |object| { |
| 765 | let Content::Outline { |
| 766 | source, |
| 767 | layout, |
| 768 | below_title: Some(title), |
| 769 | } = object |
| 770 | else { |
| 771 | return None; |
| 772 | }; |
| 773 | let origin = outline_origin( |
| 774 | crate::origin(&source.layout), |
| 775 | Some(*title), |
| 776 | Some(editor), |
| 777 | Some(column), |
| 778 | ) |
| 779 | .ok()?; |
| 780 | let text = source |
| 781 | .paragraphs |
| 782 | .iter() |
| 783 | .filter_map(|paragraph| paragraph.text()) |
| 784 | .map(|text| text.text.text()) |
| 785 | .collect::<Vec<_>>() |
| 786 | .join("\n"); |
| 787 | Some(( |
| 788 | text, |
| 789 | [ |
| 790 | origin[0], |
| 791 | origin[1], |
| 792 | origin[0] + layout.size[0], |
| 793 | origin[1] + layout.size[1], |
| 794 | ], |
| 795 | )) |
| 796 | }) |
| 797 | } |
| 798 | |
| 799 | pub(crate) fn date_fields( |
| 800 | &self, |
| 801 | editor: &CanvasEditor, |
| 802 | column: Option<&crate::reading::Column>, |
| 803 | ) -> impl Iterator<Item = (DateField, [f32; 4])> { |
| 804 | let mut fields = [None; 2]; |
| 805 | if let Some(date) = editor.date() |
| 806 | && let Some((origin, title)) = self |
| 807 | .objects(Some(editor)) |
| 808 | .into_iter() |
| 809 | .flatten() |
| 810 | .find_map(|object| match object { |
| 811 | Content::Date { below_title } => { |
| 812 | Some((crate::origin(&date.source().layout), *below_title)) |
| 813 | } |
| 814 | _ => None, |
| 815 | }) |
| 816 | && let Ok(origin) = outline_origin(origin, title, Some(editor), column) |
| 817 | { |
| 818 | for (slot, (field, paragraph)) in fields.iter_mut().zip( |
| 819 | date.fields() |
| 820 | .map(|(field, _)| field) |
| 821 | .zip(&date.layout().paragraphs), |
| 822 | ) { |
| 823 | let x = origin[0] + paragraph.origin[0]; |
| 824 | let y = origin[1] + paragraph.origin[1]; |
| 825 | let width = paragraph |
| 826 | .text |
| 827 | .lines() |
| 828 | .map(|(line, _)| line.metrics().advance) |
| 829 | .fold(0.0_f32, f32::max); |
| 830 | *slot = Some(( |
| 831 | field, |
| 832 | [x, y, x + width.max(12.0), y + paragraph.text.height()], |
| 833 | )); |
| 834 | } |
| 835 | } |
| 836 | fields.into_iter().flatten() |
| 837 | } |
| 838 | |
| 839 | /// The point and outline hit callback use scene coordinates. |
| 840 | pub fn hit_test<T>( |
| 841 | &self, |
| 842 | point: [f32; 2], |
| 843 | editor: Option<&CanvasEditor>, |
| 844 | outline: impl FnMut(onestore::ExGuid) -> Option<T>, |
| 845 | ) -> Option<SceneHit<T>> { |
| 846 | self.hit_test_with(point, editor, None, outline) |
| 847 | } |
| 848 | |
| 849 | pub(crate) fn hit_test_with<T>( |
| 850 | &self, |
| 851 | point: [f32; 2], |
| 852 | editor: Option<&CanvasEditor>, |
| 853 | column: Option<&crate::reading::Column>, |
| 854 | mut outline: impl FnMut(onestore::ExGuid) -> Option<T>, |
| 855 | ) -> Option<SceneHit<T>> { |
| 856 | let check = self |
| 857 | .object_tags(editor, column, None) |
| 858 | .into_iter() |
| 859 | .find(|(_, tag)| { |
| 860 | tag.icon.checkable() |
| 861 | && (tag.origin[0]..=tag.origin[0] + tag.size).contains(&point[0]) |
| 862 | && (tag.origin[1]..=tag.origin[1] + tag.size).contains(&point[1]) |
| 863 | }); |
| 864 | if let Some((id, _)) = check { |
| 865 | return Some(SceneHit::Check(id)); |
| 866 | } |
| 867 | let mut readonly = self.read_only(editor).count(); |
| 868 | for object in self.objects(editor).ok()?.iter().rev() { |
| 869 | match object { |
| 870 | Content::Date { .. } => { |
| 871 | if let Some(editor) = editor |
| 872 | && let Some((field, _)) = |
| 873 | self.date_fields(editor, column).find(|(_, rect)| { |
| 874 | (rect[0]..=rect[2]).contains(&point[0]) |
| 875 | && (rect[1]..=rect[3]).contains(&point[1]) |
| 876 | }) |
| 877 | { |
| 878 | return Some(SceneHit::Date(field)); |
| 879 | } |
| 880 | } |
| 881 | Content::Editable(id) => { |
| 882 | if let Some(hit) = outline(*id) { |
| 883 | return Some(SceneHit::Outline(hit)); |
| 884 | } |
| 885 | } |
| 886 | Content::ReadOnly(object) => { |
| 887 | readonly -= 1; |
| 888 | let [x0, y0, x1, y1] = object.rect(); |
| 889 | if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) { |
| 890 | return Some(SceneHit::ReadOnly(readonly)); |
| 891 | } |
| 892 | } |
| 893 | Content::Image(source) if !source.background && column.is_none() => { |
| 894 | let [x, y] = crate::origin(&source.layout); |
| 895 | if let Some([width, height]) = crate::outline::image_size(source) |
| 896 | && (x..=x + width).contains(&point[0]) |
| 897 | && (y..=y + height).contains(&point[1]) |
| 898 | { |
| 899 | return Some(SceneHit::Image(source.id)); |
| 900 | } |
| 901 | } |
| 902 | Content::File { .. } => { |
| 903 | let (file, [x0, y0, x1, y1]) = object.file()?; |
| 904 | if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) { |
| 905 | return Some(SceneHit::File(file.id)); |
| 906 | } |
| 907 | } |
| 908 | Content::Outline { .. } | Content::Image(_) | Content::Ink(_) => {} |
| 909 | } |
| 910 | } |
| 911 | None |
| 912 | } |
| 913 | |
| 914 | /// Where the page read without an editor lies, in page coordinates: the rectangle all |
| 915 | /// its content covers, and the top and bottom of each line of text and each picture, which |
| 916 | /// a sheet of paper does not cut through where it can help it. |
| 917 | #[cfg(feature = "pdf")] |
| 918 | pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> { |
| 919 | let mut bounds = [ |
| 920 | f32::INFINITY, |
| 921 | f32::INFINITY, |
| 922 | f32::NEG_INFINITY, |
| 923 | f32::NEG_INFINITY, |
| 924 | ]; |
| 925 | let mut rows = Vec::new(); |
| 926 | let mut cover = |[x0, y0, x1, y1]: [f32; 4], row: bool| { |
| 927 | bounds = [ |
| 928 | bounds[0].min(x0), |
| 929 | bounds[1].min(y0), |
| 930 | bounds[2].max(x1), |
| 931 | bounds[3].max(y1), |
| 932 | ]; |
| 933 | if row { |
| 934 | rows.push([y0, y1]); |
| 935 | } |
| 936 | }; |
| 937 | for content in self.objects(None)? { |
| 938 | match content { |
| 939 | Content::Outline { |
| 940 | source, |
| 941 | layout, |
| 942 | below_title, |
| 943 | } => { |
| 944 | let [x, y] = |
| 945 | outline_origin(crate::origin(&source.layout), *below_title, None, None)?; |
| 946 | cover([x, y, x + layout.size[0], y + layout.size[1]], false); |
| 947 | for paragraph in &layout.paragraphs { |
| 948 | let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; |
| 949 | for (line, bounds) in paragraph.text.lines() { |
| 950 | let top = py + bounds.top; |
| 951 | let right = px + line.metrics().advance; |
| 952 | cover([px, top, right, top + bounds.height], true); |
| 953 | } |
| 954 | } |
| 955 | for object in &layout.objects { |
| 956 | let [x0, y0, x1, y1] = object.rect; |
| 957 | cover([x + x0, y + y0, x + x1, y + object.bottom.max(y1)], true); |
| 958 | } |
| 959 | } |
| 960 | Content::Image(source) => { |
| 961 | let [x, y] = crate::origin(&source.layout); |
| 962 | let size = match self.backgrounds.get(&source.id) { |
| 963 | Some(art) => Some(art.size), |
| 964 | None => crate::outline::image_size(source), |
| 965 | }; |
| 966 | if let Some([width, height]) = size { |
| 967 | cover([x, y, x + width, y + height], !source.background); |
| 968 | } |
| 969 | } |
| 970 | Content::File { .. } => { |
| 971 | if let Some((_, rect)) = content.file() { |
| 972 | cover(rect, true); |
| 973 | } |
| 974 | } |
| 975 | Content::Ink(ink) => { |
| 976 | if let Some(rect) = crate::editor::page::ink_bounds(ink) { |
| 977 | cover(rect, false); |
| 978 | } |
| 979 | } |
| 980 | Content::ReadOnly(object) => cover(object.rect(), true), |
| 981 | Content::Date { .. } | Content::Editable(_) => {} |
| 982 | } |
| 983 | } |
| 984 | Ok((bounds, rows)) |
| 985 | } |
| 986 | |
| 987 | /// The offset is in document points. |
| 988 | pub fn append_primitives<'a>( |
| 989 | &'a self, |
| 990 | primitives: &mut Vec<Primitive<'a>>, |
| 991 | offset: [f32; 2], |
| 992 | paper: Paper, |
| 993 | ) -> Result<(), SceneError> { |
| 994 | self.append_primitives_with(primitives, offset, None, None, None, paper, |_, _, _| { |
| 995 | Err(SceneError::MissingOutline) |
| 996 | }) |
| 997 | } |
| 998 | |
| 999 | /// `moving` draws one picture at a previewed rectangle instead of its stored layout. |
| 1000 | /// Outlines the editor does not hold draw their tags' symbols. |
| 1001 | #[allow(clippy::too_many_arguments)] |
| 1002 | pub(crate) fn append_primitives_with<'a, E: From<SceneError>>( |
| 1003 | &'a self, |
| 1004 | primitives: &mut Vec<Primitive<'a>>, |
| 1005 | offset: [f32; 2], |
| 1006 | editor: Option<&'a CanvasEditor>, |
| 1007 | column: Option<&'a crate::reading::Column>, |
| 1008 | moving: Option<(onestore::ExGuid, [f32; 4])>, |
| 1009 | paper: Paper, |
| 1010 | mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>, |
| 1011 | ) -> Result<(), E> { |
| 1012 | for content in self.objects(editor)? { |
| 1013 | let origin = match content { |
| 1014 | Content::Outline { |
| 1015 | source, |
| 1016 | below_title, |
| 1017 | .. |
| 1018 | } => outline_origin(crate::origin(&source.layout), *below_title, editor, column)?, |
| 1019 | Content::Date { below_title } => { |
| 1020 | let date = editor |
| 1021 | .and_then(CanvasEditor::date) |
| 1022 | .ok_or(SceneError::MissingOutline)?; |
| 1023 | outline_origin( |
| 1024 | crate::origin(&date.source().layout), |
| 1025 | *below_title, |
| 1026 | editor, |
| 1027 | column, |
| 1028 | )? |
| 1029 | } |
| 1030 | Content::Image(source) => match column { |
| 1031 | Some(column) => match column.picture(source.id) { |
| 1032 | Some([x, y, ..]) => [x, y], |
| 1033 | None => continue, |
| 1034 | }, |
| 1035 | None => crate::origin(&source.layout), |
| 1036 | }, |
| 1037 | Content::ReadOnly(object) => { |
| 1038 | let rect = crate::translated(object.rect(), offset); |
| 1039 | append_placeholder(rect, paper, primitives); |
| 1040 | primitives.push(Primitive::Text { |
| 1041 | clip: None, |
| 1042 | text: &object.label, |
| 1043 | origin: [rect[0] + 8.0, rect[1] + 8.0], |
| 1044 | ink: paper.shade(colorref(0x005d554e)), |
| 1045 | }); |
| 1046 | continue; |
| 1047 | } |
| 1048 | Content::Editable(id) => { |
| 1049 | outline(*id, offset, primitives)?; |
| 1050 | continue; |
| 1051 | } |
| 1052 | Content::Ink(_) if column.is_some() => continue, |
| 1053 | Content::Ink(ink) => { |
| 1054 | let [x, y] = crate::origin(&ink.layout); |
| 1055 | let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id)); |
| 1056 | let offset = [offset[0] + x + dx, offset[1] + y + dy]; |
| 1057 | append_ink(ink, offset, paper.ink, primitives); |
| 1058 | continue; |
| 1059 | } |
| 1060 | Content::File { source, layout } => { |
| 1061 | // A dragged file draws where its column would land. |
| 1062 | let [x, y] = match moving { |
| 1063 | Some((id, [x0, y0, ..])) if id == source.id => [x0, y0], |
| 1064 | _ => crate::origin(&source.layout), |
| 1065 | }; |
| 1066 | self.append_object( |
| 1067 | layout, |
| 1068 | [x + offset[0], y + offset[1]], |
| 1069 | None, |
| 1070 | paper, |
| 1071 | primitives, |
| 1072 | ); |
| 1073 | continue; |
| 1074 | } |
| 1075 | }; |
| 1076 | let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; |
| 1077 | match content { |
| 1078 | Content::Image(source) => { |
| 1079 | let (image, [width, height]) = match self.backgrounds.get(&source.id) { |
| 1080 | Some(art) => { |
| 1081 | // OneNote's art is opaque, over white, and hides the rule lines. |
| 1082 | primitives.push(Primitive::Rect { |
| 1083 | rect: [ |
| 1084 | object_origin[0], |
| 1085 | object_origin[1], |
| 1086 | object_origin[0] + art.size[0], |
| 1087 | object_origin[1] + art.size[1], |
| 1088 | ], |
| 1089 | color: if background::dark(paper) { |
| 1090 | paper.color |
| 1091 | } else { |
| 1092 | Paper::WHITE.color |
| 1093 | }, |
| 1094 | }); |
| 1095 | (art.image(paper), art.size) |
| 1096 | } |
| 1097 | None => { |
| 1098 | let picture = self |
| 1099 | .pictures |
| 1100 | .get(&source.id) |
| 1101 | .ok_or(SceneError::MissingImage)?; |
| 1102 | let size = match column.and_then(|column| column.picture(source.id)) { |
| 1103 | Some([x0, y0, x1, y1]) => [x1 - x0, y1 - y0], |
| 1104 | None => crate::outline::image_size(source) |
| 1105 | .ok_or(SceneError::MissingImage)?, |
| 1106 | }; |
| 1107 | if picture.failed() { |
| 1108 | append_placeholder( |
| 1109 | [ |
| 1110 | object_origin[0], |
| 1111 | object_origin[1], |
| 1112 | object_origin[0] + size[0], |
| 1113 | object_origin[1] + size[1], |
| 1114 | ], |
| 1115 | paper, |
| 1116 | primitives, |
| 1117 | ); |
| 1118 | } |
| 1119 | (picture.image(), size) |
| 1120 | } |
| 1121 | }; |
| 1122 | let Some(image) = image else { |
| 1123 | continue; |
| 1124 | }; |
| 1125 | let rect = match moving { |
| 1126 | Some((id, rect)) if id == source.id => crate::translated(rect, offset), |
| 1127 | _ => [ |
| 1128 | object_origin[0], |
| 1129 | object_origin[1], |
| 1130 | object_origin[0] + width, |
| 1131 | object_origin[1] + height, |
| 1132 | ], |
| 1133 | }; |
| 1134 | primitives.push(Primitive::Image { image, rect }); |
| 1135 | // OneNote frames a printout's page with a one-pixel grey line. |
| 1136 | if source.printout.is_some() { |
| 1137 | let [x0, y0, x1, y1] = rect; |
| 1138 | primitives.push(Primitive::RoundedRect { |
| 1139 | rect: [x0 + 0.375, y0 + 0.375, x1 - 0.375, y1 - 0.375], |
| 1140 | radius: [0.0; 2], |
| 1141 | stroke: Some(draw::Stroke::Solid(0.75)), |
| 1142 | color: paper.shade(colorref(0x00cc_cccc)), |
| 1143 | }); |
| 1144 | } |
| 1145 | } |
| 1146 | Content::Outline { .. } | Content::Date { .. } => { |
| 1147 | let outline = match content { |
| 1148 | Content::Outline { layout, .. } => layout, |
| 1149 | _ => editor |
| 1150 | .and_then(CanvasEditor::date) |
| 1151 | .ok_or(SceneError::MissingOutline)? |
| 1152 | .layout(), |
| 1153 | }; |
| 1154 | outline.append_table_primitives(primitives, object_origin, paper); |
| 1155 | let art = super::TagArt::default(); |
| 1156 | outline.append_block_tag_primitives(primitives, object_origin, &art); |
| 1157 | let everything = [f32::NEG_INFINITY, f32::INFINITY]; |
| 1158 | outline.append_background_primitives( |
| 1159 | primitives, |
| 1160 | object_origin, |
| 1161 | everything, |
| 1162 | paper, |
| 1163 | ); |
| 1164 | self.append_outline_objects(outline, object_origin, None, paper, primitives); |
| 1165 | for (index, paragraph) in outline.visible(everything) { |
| 1166 | outline.append_paragraph_primitives( |
| 1167 | index, |
| 1168 | paragraph, |
| 1169 | object_origin, |
| 1170 | paper.ink, |
| 1171 | &art, |
| 1172 | primitives, |
| 1173 | ); |
| 1174 | } |
| 1175 | } |
| 1176 | Content::Editable(_) |
| 1177 | | Content::ReadOnly(_) |
| 1178 | | Content::Ink(_) |
| 1179 | | Content::File { .. } => unreachable!(), |
| 1180 | } |
| 1181 | } |
| 1182 | let art = super::TagArt::default(); |
| 1183 | for (_, tag) in self.object_tags(editor, column, moving) { |
| 1184 | primitives.push(Primitive::Icon { |
| 1185 | sources: art.sources(&tag), |
| 1186 | origin: [tag.origin[0] + offset[0], tag.origin[1] + offset[1]], |
| 1187 | size: tag.size, |
| 1188 | tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], |
| 1189 | palette: draw::Palette::default(), |
| 1190 | }); |
| 1191 | } |
| 1192 | Ok(()) |
| 1193 | } |
| 1194 | } |
| 1195 | |
| 1196 | impl crate::outline::OutlineLayout { |
| 1197 | /// Paragraphs that may paint between outline-local `rows`, with their indices. |
| 1198 | pub(crate) fn visible( |
| 1199 | &self, |
| 1200 | rows: [f32; 2], |
| 1201 | ) -> impl Iterator<Item = (usize, &crate::outline::ParagraphLayout)> { |
| 1202 | // Markers, tags and overhanging ink stay within this of a paragraph's lines. |
| 1203 | const OVERHANG: f32 = 72.0; |
| 1204 | self.paragraphs |
| 1205 | .iter() |
| 1206 | .enumerate() |
| 1207 | .filter(move |(_, paragraph)| { |
| 1208 | let [start, end] = [ |
| 1209 | paragraph.origin[1] - OVERHANG, |
| 1210 | paragraph.origin[1] + paragraph.text.height() + OVERHANG, |
| 1211 | ]; |
| 1212 | end >= rows[0] && start <= rows[1] |
| 1213 | }) |
| 1214 | } |
| 1215 | |
| 1216 | /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black |
| 1217 | /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. |
| 1218 | /// A paragraph's band spans its outline, or its cell, tinted for the paper. |
| 1219 | pub(crate) fn append_background_primitives( |
| 1220 | &self, |
| 1221 | primitives: &mut Vec<Primitive<'_>>, |
| 1222 | origin: [f32; 2], |
| 1223 | rows: [f32; 2], |
| 1224 | paper: Paper, |
| 1225 | ) { |
| 1226 | for (index, paragraph) in self.visible(rows) { |
| 1227 | if let Some(band) = paragraph.band { |
| 1228 | let mut rect = parley::BoundingBox::new( |
| 1229 | 0.0, |
| 1230 | f64::from(paragraph.origin[1]), |
| 1231 | f64::from(self.size[0]), |
| 1232 | f64::from(paragraph.origin[1] + paragraph.text.height()), |
| 1233 | ); |
| 1234 | let cell = self.paragraph_cell(index); |
| 1235 | if let Some(clipped) = cell.map_or(Some(rect), |cell| cell.clip(rect)) { |
| 1236 | rect = clipped; |
| 1237 | primitives.push(Primitive::Rect { |
| 1238 | rect: [ |
| 1239 | rect.x0 as f32 + origin[0], |
| 1240 | rect.y0 as f32 + origin[1], |
| 1241 | rect.x1 as f32 + origin[0], |
| 1242 | rect.y1 as f32 + origin[1], |
| 1243 | ], |
| 1244 | color: paper.tint(colorref(band)), |
| 1245 | }); |
| 1246 | } |
| 1247 | } |
| 1248 | for (mut rect, color) in paragraph.text.backgrounds() { |
| 1249 | rect.x0 += f64::from(paragraph.origin[0]); |
| 1250 | rect.x1 += f64::from(paragraph.origin[0]); |
| 1251 | rect.y0 += f64::from(paragraph.origin[1]); |
| 1252 | rect.y1 += f64::from(paragraph.origin[1]); |
| 1253 | if let Some(cell) = self.paragraph_cell(index) { |
| 1254 | let Some(clipped) = cell.clip(rect) else { |
| 1255 | continue; |
| 1256 | }; |
| 1257 | rect = clipped; |
| 1258 | } |
| 1259 | primitives.push(Primitive::Rect { |
| 1260 | rect: [ |
| 1261 | rect.x0 as f32 + origin[0], |
| 1262 | rect.y0 as f32 + origin[1], |
| 1263 | rect.x1 as f32 + origin[0], |
| 1264 | rect.y1 as f32 + origin[1], |
| 1265 | ], |
| 1266 | color: if color == 0 { |
| 1267 | paper.ink |
| 1268 | } else { |
| 1269 | colorref(color) |
| 1270 | }, |
| 1271 | }); |
| 1272 | } |
| 1273 | } |
| 1274 | } |
| 1275 | |
| 1276 | /// One paragraph of this outline, whose origin is `origin`: its text or equation, list |
| 1277 | /// markers and tags, those `art` maps drawn with their art. |
| 1278 | pub(crate) fn append_paragraph_primitives<'a>( |
| 1279 | &'a self, |
| 1280 | index: usize, |
| 1281 | paragraph: &'a crate::outline::ParagraphLayout, |
| 1282 | origin: [f32; 2], |
| 1283 | ink: [f32; 4], |
| 1284 | art: &super::TagArt, |
| 1285 | primitives: &mut Vec<Primitive<'a>>, |
| 1286 | ) { |
| 1287 | let [x, y] = [ |
| 1288 | origin[0] + paragraph.origin[0], |
| 1289 | origin[1] + paragraph.origin[1], |
| 1290 | ]; |
| 1291 | let clip = self.paragraph_cell(index).map(|cell| { |
| 1292 | let [left, top, right, bottom] = cell.text_bounds(); |
| 1293 | [ |
| 1294 | left + origin[0], |
| 1295 | top + origin[1], |
| 1296 | right + origin[0], |
| 1297 | bottom + origin[1], |
| 1298 | ] |
| 1299 | }); |
| 1300 | primitives.push(Primitive::Text { |
| 1301 | clip, |
| 1302 | text: &paragraph.text, |
| 1303 | origin: [x, y], |
| 1304 | ink, |
| 1305 | }); |
| 1306 | for (index, [left, baseline]) in paragraph.text.spaces() { |
| 1307 | let math = &paragraph.math[index]; |
| 1308 | let [x, y] = [x + left, y + baseline - math.baseline]; |
| 1309 | for item in &math.items { |
| 1310 | primitives.push(match item { |
| 1311 | crate::math::MathItem::Text { layout, origin } => Primitive::Text { |
| 1312 | clip, |
| 1313 | text: layout, |
| 1314 | origin: [x + origin[0], y + origin[1]], |
| 1315 | ink, |
| 1316 | }, |
| 1317 | // As pen strokes, so hairline rules keep a device pixel. |
| 1318 | crate::math::MathItem::Rule([x0, y0, x1, y1]) => { |
| 1319 | let width = (x1 - x0).min(y1 - y0); |
| 1320 | let [from, to] = if x1 - x0 >= y1 - y0 { |
| 1321 | let middle = (y0 + y1) / 2.0; |
| 1322 | [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]] |
| 1323 | } else { |
| 1324 | let middle = (x0 + x1) / 2.0; |
| 1325 | [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]] |
| 1326 | }; |
| 1327 | Primitive::Segment { |
| 1328 | from: [x + from[0], y + from[1]], |
| 1329 | to: [x + to[0], y + to[1]], |
| 1330 | width, |
| 1331 | round: false, |
| 1332 | color: math.color.map_or(ink, colorref), |
| 1333 | } |
| 1334 | } |
| 1335 | crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment { |
| 1336 | from: [x + from[0], y + from[1]], |
| 1337 | to: [x + to[0], y + to[1]], |
| 1338 | width: *width, |
| 1339 | round: true, |
| 1340 | color: math.color.map_or(ink, colorref), |
| 1341 | }, |
| 1342 | }); |
| 1343 | } |
| 1344 | } |
| 1345 | for (layout, marker) in &paragraph.markers { |
| 1346 | primitives.push(Primitive::Text { |
| 1347 | clip, |
| 1348 | text: layout, |
| 1349 | origin: [origin[0] + marker[0], y + marker[1]], |
| 1350 | ink, |
| 1351 | }); |
| 1352 | } |
| 1353 | for tag in &paragraph.tags { |
| 1354 | primitives.push(Primitive::Icon { |
| 1355 | sources: art.sources(tag), |
| 1356 | origin: [ |
| 1357 | origin[0] + self.tag_column_offset() + tag.origin[0], |
| 1358 | y + tag.origin[1], |
| 1359 | ], |
| 1360 | size: tag.size, |
| 1361 | tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], |
| 1362 | palette: draw::Palette::default(), |
| 1363 | }); |
| 1364 | } |
| 1365 | } |
| 1366 | |
| 1367 | /// The note tags of this outline's tables, pictures and files, whose origin is `origin`, |
| 1368 | /// those `art` maps drawn with their art. |
| 1369 | pub(crate) fn append_block_tag_primitives( |
| 1370 | &self, |
| 1371 | primitives: &mut Vec<Primitive<'_>>, |
| 1372 | origin: [f32; 2], |
| 1373 | art: &super::TagArt, |
| 1374 | ) { |
| 1375 | let tables = self.tables.iter().filter_map(|table| table.tags.as_ref()); |
| 1376 | let objects = self |
| 1377 | .objects |
| 1378 | .iter() |
| 1379 | .filter_map(|object| object.tags.as_ref()); |
| 1380 | for tag in tables.chain(objects).flat_map(|block| &block.tags) { |
| 1381 | primitives.push(Primitive::Icon { |
| 1382 | sources: art.sources(tag), |
| 1383 | origin: [ |
| 1384 | origin[0] + self.tag_column_offset() + tag.origin[0], |
| 1385 | origin[1] + tag.origin[1], |
| 1386 | ], |
| 1387 | size: tag.size, |
| 1388 | tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], |
| 1389 | palette: draw::Palette::default(), |
| 1390 | }); |
| 1391 | } |
| 1392 | } |
| 1393 | |
| 1394 | pub(crate) fn append_table_primitives( |
| 1395 | &self, |
| 1396 | primitives: &mut Vec<Primitive<'_>>, |
| 1397 | origin: [f32; 2], |
| 1398 | paper: Paper, |
| 1399 | ) { |
| 1400 | for table in &self.tables { |
| 1401 | let (Some(first), Some(last)) = (table.cells.first(), table.cells.last()) else { |
| 1402 | continue; |
| 1403 | }; |
| 1404 | if !table.borders { |
| 1405 | continue; |
| 1406 | } |
| 1407 | let bounds = [first.rect[0], first.rect[1], last.rect[2], last.rect[3]]; |
| 1408 | let color = paper.shade(colorref(0x00a3a3a3)); |
| 1409 | primitives.push(Primitive::RoundedRect { |
| 1410 | rect: [ |
| 1411 | bounds[0] + origin[0] - 0.375, |
| 1412 | bounds[1] + origin[1] - 0.375, |
| 1413 | bounds[2] + origin[0] + 0.375, |
| 1414 | bounds[3] + origin[1] + 0.375, |
| 1415 | ], |
| 1416 | radius: [3.6; 2], |
| 1417 | stroke: Some(draw::Stroke::Solid(0.75)), |
| 1418 | color, |
| 1419 | }); |
| 1420 | for cell in &table.cells { |
| 1421 | let [left, top, right, bottom] = cell.rect; |
| 1422 | if right < bounds[2] { |
| 1423 | primitives.push(Primitive::Rect { |
| 1424 | rect: [ |
| 1425 | right + origin[0] - 0.375, |
| 1426 | top + origin[1], |
| 1427 | right + origin[0] + 0.375, |
| 1428 | bottom + origin[1], |
| 1429 | ], |
| 1430 | color, |
| 1431 | }); |
| 1432 | } |
| 1433 | if bottom < bounds[3] { |
| 1434 | primitives.push(Primitive::Rect { |
| 1435 | rect: [ |
| 1436 | left + origin[0], |
| 1437 | bottom + origin[1] - 0.375, |
| 1438 | right + origin[0], |
| 1439 | bottom + origin[1] + 0.375, |
| 1440 | ], |
| 1441 | color, |
| 1442 | }); |
| 1443 | } |
| 1444 | } |
| 1445 | } |
| 1446 | } |
| 1447 | } |
| 1448 | |
| 1449 | #[cfg(test)] |
| 1450 | mod tests { |
| 1451 | use super::*; |
| 1452 | use crate::editor::TextOutline; |
| 1453 | use crate::gpu::painted_layout; |
| 1454 | use onestore::page::text::Paragraph; |
| 1455 | use onestore::page::{Image, PageObject}; |
| 1456 | use onestore::{ExGuid, document::Layout}; |
| 1457 | use std::{collections::BTreeMap, sync::Arc}; |
| 1458 | |
| 1459 | #[test] |
| 1460 | fn title_editing_keeps_identity_and_date_geometry_through_composition_and_undo() { |
| 1461 | use crate::document::TextDocument; |
| 1462 | use onestore::page::Title; |
| 1463 | let mut engine = TextEngine::default(); |
| 1464 | let mut fields = ["Header", "September 8, 2026"].map(|text| { |
| 1465 | let document = |
| 1466 | TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap(); |
| 1467 | TextOutline::new(&mut engine, document, 468.0, [0.0; 2]) |
| 1468 | .unwrap() |
| 1469 | .snapshot() |
| 1470 | }); |
| 1471 | for field in &mut fields { |
| 1472 | field.layout.max_width = None; |
| 1473 | field.layout.width_set_by_user = None; |
| 1474 | field.layout.max_height = Some(21.6); |
| 1475 | } |
| 1476 | fields[0].title = true; |
| 1477 | fields[0].min_width = Some(162.0); |
| 1478 | fields[1].layout.x = Some(2.0); |
| 1479 | fields[1].layout.y = Some(3.0); |
| 1480 | let id = fields[0].id; |
| 1481 | let source = fields[0].paragraphs.clone(); |
| 1482 | let page = Page { |
| 1483 | identity: None, |
| 1484 | created: Some(1), |
| 1485 | title: "Header".into(), |
| 1486 | margin_origin: [36.0, 14.4], |
| 1487 | rtl: false, |
| 1488 | color: None, |
| 1489 | rule_lines: None, |
| 1490 | definitions: BTreeMap::new(), |
| 1491 | objects: vec![PageObject::Title(Title { |
| 1492 | date: Some(fields[1].id), |
| 1493 | id: ExGuid::default(), |
| 1494 | layout: Layout { |
| 1495 | x: Some(10.0), |
| 1496 | y: Some(20.0), |
| 1497 | ..Default::default() |
| 1498 | }, |
| 1499 | outlines: fields.into(), |
| 1500 | })], |
| 1501 | }; |
| 1502 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 1503 | assert!(editor.active_outline().title); |
| 1504 | assert_eq!(editor.active_outline().origin(), [46.0, 34.4]); |
| 1505 | assert_eq!(editor.active_outline().layout().max_width, None); |
| 1506 | let bounds = |editor: &CanvasEditor| { |
| 1507 | scene |
| 1508 | .content_bounds(editor, None) |
| 1509 | .map(|(rect, _)| rect) |
| 1510 | .collect::<Vec<_>>() |
| 1511 | }; |
| 1512 | let before = bounds(&editor); |
| 1513 | assert_eq!(before[0][0], 48.0); |
| 1514 | assert!((before[0][1] - 62.6).abs() < 0.00001); |
| 1515 | let text = "A long title with multiple wrapped lines ".repeat(10); |
| 1516 | editor.select_all().unwrap(); |
| 1517 | let end = text.encode_utf16().count() as u32; |
| 1518 | editor.compose(&mut engine, text.clone(), end..end).unwrap(); |
| 1519 | let wrapped = bounds(&editor); |
| 1520 | assert!(wrapped[0][1] > before[0][1] + 20.0); |
| 1521 | assert_eq!( |
| 1522 | wrapped[0][1], |
| 1523 | editor.active_outline().bounds().y1 as f32 + 3.0 + 3.6 |
| 1524 | ); |
| 1525 | editor.cancel_composition(&mut engine).unwrap(); |
| 1526 | assert_eq!(editor.active_outline().document().nodes(), source); |
| 1527 | assert_eq!(bounds(&editor), before); |
| 1528 | editor.commit_text(&mut engine, text).unwrap(); |
| 1529 | assert_eq!(bounds(&editor), wrapped); |
| 1530 | assert_eq!(editor.active_outline().id, id); |
| 1531 | assert_eq!( |
| 1532 | editor.active_outline().document().nodes()[0].id, |
| 1533 | source[0].id |
| 1534 | ); |
| 1535 | editor.undo(&mut engine).unwrap(); |
| 1536 | assert_eq!(editor.active_outline().document().nodes(), source); |
| 1537 | assert_eq!(bounds(&editor), before); |
| 1538 | editor.redo(&mut engine).unwrap(); |
| 1539 | editor.select_all().unwrap(); |
| 1540 | editor.insert(&mut engine, "").unwrap(); |
| 1541 | assert_eq!(editor.outlines().len(), 1); |
| 1542 | assert!(editor.caret_outline().is_none()); |
| 1543 | assert_eq!(editor.active_outline().id, id); |
| 1544 | assert_eq!(bounds(&editor), before); |
| 1545 | assert!(editor.move_outline(id, [0.0; 2]).is_err()); |
| 1546 | assert!(editor.resize(&mut engine, 90.0).is_err()); |
| 1547 | editor.undo(&mut engine).unwrap(); |
| 1548 | let primitives = paint_editor(&scene, &editor); |
| 1549 | let Primitive::Text { origin, .. } = primitives.last().unwrap() else { |
| 1550 | panic!() |
| 1551 | }; |
| 1552 | assert_eq!(origin[1], wrapped[0][1]); |
| 1553 | let title = editor.active_outline().document().clone(); |
| 1554 | editor |
| 1555 | .change_date( |
| 1556 | &mut engine, |
| 1557 | 2, |
| 1558 | ["A date label that wraps ".repeat(30), String::new()], |
| 1559 | ) |
| 1560 | .unwrap(); |
| 1561 | let changed = bounds(&editor); |
| 1562 | assert_eq!(changed[0][1], wrapped[0][1]); |
| 1563 | assert!(changed[0][3] > wrapped[0][3]); |
| 1564 | let (field, rect) = scene.date_fields(&editor, None).next().unwrap(); |
| 1565 | assert_eq!(field, DateField::Date); |
| 1566 | assert_eq!(rect[1], changed[0][1]); |
| 1567 | assert_eq!(rect[3], changed[0][3]); |
| 1568 | editor.undo(&mut engine).unwrap(); |
| 1569 | assert_eq!(editor.date().unwrap().timestamp(), 1); |
| 1570 | assert_eq!(bounds(&editor), wrapped); |
| 1571 | assert_eq!(editor.active_outline().document(), &title); |
| 1572 | } |
| 1573 | |
| 1574 | #[test] |
| 1575 | #[ignore = "requires the native baseline section via CANVAS_TEST_SECTION"] |
| 1576 | fn native_title_metrics_and_exit() { |
| 1577 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); |
| 1578 | let store = onestore::Store::parse(&bytes).unwrap(); |
| 1579 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 1580 | let document = onestore::document::Document::parse(&index).unwrap(); |
| 1581 | let page = Page::from_document(&document, "Baseline anchors").unwrap(); |
| 1582 | let mut engine = TextEngine::default(); |
| 1583 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); |
| 1584 | let title_id = editor |
| 1585 | .outlines() |
| 1586 | .iter() |
| 1587 | .find(|outline| outline.title) |
| 1588 | .unwrap() |
| 1589 | .id; |
| 1590 | for (text, lines, y) in [ |
| 1591 | ("Header", 1, 86.4), |
| 1592 | ( |
| 1593 | "A page title that is intentionally long enough to wrap across several lines at this window size while retaining its own date and time below the text", |
| 1594 | 3, |
| 1595 | 140.4, |
| 1596 | ), |
| 1597 | ] { |
| 1598 | editor.focus_outline(title_id).unwrap(); |
| 1599 | editor.select_all().unwrap(); |
| 1600 | editor.insert(&mut engine, text).unwrap(); |
| 1601 | let paragraph = editor.active_outline().layouts().next().unwrap().1; |
| 1602 | assert_eq!(paragraph.text.lines().count(), lines); |
| 1603 | assert!((paragraph.text.lines().next().unwrap().1.height - 20.751953).abs() < 0.001); |
| 1604 | editor.leave_title(&mut engine).unwrap(); |
| 1605 | assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, y]); |
| 1606 | editor.undo(&mut engine).unwrap(); |
| 1607 | } |
| 1608 | let date = editor.date().unwrap(); |
| 1609 | let timestamp = date.timestamp(); |
| 1610 | let source = date.source().paragraphs.clone(); |
| 1611 | assert_eq!( |
| 1612 | date.fields().map(|(field, _)| field).collect::<Vec<_>>(), |
| 1613 | [DateField::Date, DateField::Time] |
| 1614 | ); |
| 1615 | let positions = editor |
| 1616 | .outlines() |
| 1617 | .iter() |
| 1618 | .map(TextOutline::origin) |
| 1619 | .collect::<Vec<_>>(); |
| 1620 | editor |
| 1621 | .change_date( |
| 1622 | &mut engine, |
| 1623 | timestamp + 2 * 86_400 * 10_000_000, |
| 1624 | ["Wednesday, September 09, 2026".into(), "6:14 AM".into()], |
| 1625 | ) |
| 1626 | .unwrap(); |
| 1627 | assert_eq!( |
| 1628 | editor |
| 1629 | .outlines() |
| 1630 | .iter() |
| 1631 | .map(TextOutline::origin) |
| 1632 | .collect::<Vec<_>>(), |
| 1633 | positions |
| 1634 | ); |
| 1635 | assert_eq!(editor.date().unwrap().source().paragraphs[1], source[1]); |
| 1636 | editor.undo(&mut engine).unwrap(); |
| 1637 | assert_eq!(editor.date().unwrap().timestamp(), timestamp); |
| 1638 | assert_eq!(editor.date().unwrap().source().paragraphs, source); |
| 1639 | for (run_size, base_size, screen_y, paragraphs) in [ |
| 1640 | (11.0, 11.0, 235.0, Some(2)), |
| 1641 | (11.0, 11.0, 252.0, Some(3)), |
| 1642 | (11.0, 11.0, 256.0, Some(3)), |
| 1643 | (11.0, 11.0, 260.0, None), |
| 1644 | (22.0, 11.0, 306.0, Some(3)), |
| 1645 | (22.0, 22.0, 306.0, Some(2)), |
| 1646 | (22.0, 22.0, 324.0, None), |
| 1647 | ] { |
| 1648 | let style = ExGuid { |
| 1649 | n: 900, |
| 1650 | ..Default::default() |
| 1651 | }; |
| 1652 | let format = onestore::document::Format { |
| 1653 | font: Some("Calibri".into()), |
| 1654 | font_size: Some(run_size), |
| 1655 | ..Default::default() |
| 1656 | }; |
| 1657 | let mut nodes = crate::document::TextDocument::new(vec![Paragraph::new( |
| 1658 | "Anchor0".into(), |
| 1659 | format.clone(), |
| 1660 | )]) |
| 1661 | .unwrap() |
| 1662 | .nodes() |
| 1663 | .to_vec(); |
| 1664 | nodes[0].style = Some(style); |
| 1665 | let outline = TextOutline::new( |
| 1666 | &mut engine, |
| 1667 | crate::document::TextDocument::from_nodes(nodes).unwrap(), |
| 1668 | 72.0, |
| 1669 | [120.0, 90.0], |
| 1670 | ) |
| 1671 | .unwrap(); |
| 1672 | let id = outline.id; |
| 1673 | let source = outline.document().clone(); |
| 1674 | let mut editor = CanvasEditor::from_text_outlines( |
| 1675 | vec![outline], |
| 1676 | BTreeMap::from([( |
| 1677 | style, |
| 1678 | onestore::page::Definition { |
| 1679 | kind: onestore::document::Kind::Style { |
| 1680 | name: Some("p".into()), |
| 1681 | next: None, |
| 1682 | }, |
| 1683 | format: onestore::document::Format { |
| 1684 | font_size: Some(base_size), |
| 1685 | ..format |
| 1686 | }, |
| 1687 | }, |
| 1688 | )]), |
| 1689 | None, |
| 1690 | ) |
| 1691 | .unwrap(); |
| 1692 | let point = [ |
| 1693 | (220.0 - 48.0) * 0.75 - 120.0, |
| 1694 | (screen_y - 83.0) * 0.75 - 90.0, |
| 1695 | ]; |
| 1696 | assert_eq!( |
| 1697 | editor.select_below(&mut engine, id, point).unwrap(), |
| 1698 | paragraphs.is_some(), |
| 1699 | "run={run_size} base={base_size} y={screen_y}" |
| 1700 | ); |
| 1701 | assert_eq!(editor.outlines()[0].document(), &source); |
| 1702 | if let Some(count) = paragraphs { |
| 1703 | editor.insert(&mut engine, "X").unwrap(); |
| 1704 | assert_eq!(editor.active_outline().document().nodes().len(), count); |
| 1705 | assert_eq!(editor.active_outline().origin(), [120.0, 90.0]); |
| 1706 | assert_eq!( |
| 1707 | editor |
| 1708 | .active_outline() |
| 1709 | .document() |
| 1710 | .paragraphs() |
| 1711 | .last() |
| 1712 | .unwrap() |
| 1713 | .text(), |
| 1714 | "X" |
| 1715 | ); |
| 1716 | assert!( |
| 1717 | (editor |
| 1718 | .active_outline() |
| 1719 | .layouts() |
| 1720 | .last() |
| 1721 | .unwrap() |
| 1722 | .1 |
| 1723 | .text |
| 1724 | .height() |
| 1725 | - base_size * (12.207_031 / 10.0)) |
| 1726 | .abs() |
| 1727 | < 0.001 |
| 1728 | ); |
| 1729 | } |
| 1730 | } |
| 1731 | } |
| 1732 | |
| 1733 | #[test] |
| 1734 | #[ignore = "requires the native baseline section via CANVAS_TEST_SECTION"] |
| 1735 | fn native_title_flow() { |
| 1736 | use crate::document::TextDocument; |
| 1737 | use onestore::page::Image; |
| 1738 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); |
| 1739 | let store = onestore::Store::parse(&bytes).unwrap(); |
| 1740 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 1741 | let document = onestore::document::Document::parse(&index).unwrap(); |
| 1742 | let mut engine = TextEngine::default(); |
| 1743 | let long = "A page title that is intentionally long enough to wrap across several lines at this window size while retaining its own date and time below the text"; |
| 1744 | for (font_size, text, x, y, expected) in [ |
| 1745 | (17.0, long, 120.0, 90.0, 116.18992), |
| 1746 | (17.0, long, 120.0, 110.0, 116.18992), |
| 1747 | (17.0, long, 120.0, 150.0, 150.0), |
| 1748 | (17.0, long, 120.0, 73.8, 73.8), |
| 1749 | (17.0, long, 120.0, 74.0, 116.18992), |
| 1750 | (17.0, long, 120.0, 40.0, 40.0), |
| 1751 | (17.0, long, 540.0, 90.0, 116.18992), |
| 1752 | (17.0, long, 550.0, 90.0, 90.0), |
| 1753 | (17.0, long, 600.0, 90.0, 90.0), |
| 1754 | (17.0, long, 0.0, 90.0, 116.18992), |
| 1755 | (17.0, long, -50.0, 90.0, 116.18992), |
| 1756 | (17.0, long, -100.0, 90.0, 90.0), |
| 1757 | (17.0, "Header\u{000b}Second", 120.0, 90.0, 95.43796), |
| 1758 | (17.0, "Header\u{000b}Second", 250.0, 90.0, 95.43796), |
| 1759 | (17.0, "Header\u{000b}Second", 540.0, 90.0, 95.43796), |
| 1760 | (17.0, "Header\u{000b}Second", 550.0, 90.0, 90.0), |
| 1761 | (11.0, long, 120.0, 60.0, 60.0), |
| 1762 | (11.0, long, 120.0, 70.0, 80.78945), |
| 1763 | (22.0, long, 120.0, 80.0, 80.0), |
| 1764 | (22.0, long, 120.0, 90.0, 134.5003), |
| 1765 | ] { |
| 1766 | let mut page = Page::from_document(&document, "Baseline anchors").unwrap(); |
| 1767 | page.objects |
| 1768 | .retain(|object| matches!(object, PageObject::Title(_))); |
| 1769 | let PageObject::Title(title) = &mut page.objects[0] else { |
| 1770 | unreachable!() |
| 1771 | }; |
| 1772 | let title = title |
| 1773 | .outlines |
| 1774 | .iter_mut() |
| 1775 | .find(|outline| outline.title) |
| 1776 | .unwrap(); |
| 1777 | title.paragraphs[0].text_mut().unwrap().text = Paragraph::new( |
| 1778 | "Header".into(), |
| 1779 | onestore::document::Format { |
| 1780 | font: Some("Calibri".into()), |
| 1781 | font_size: Some(font_size), |
| 1782 | ..Default::default() |
| 1783 | }, |
| 1784 | ); |
| 1785 | let body = TextOutline::new( |
| 1786 | &mut engine, |
| 1787 | TextDocument::new(vec![Paragraph::new("Anchor".into(), Default::default())]) |
| 1788 | .unwrap(), |
| 1789 | 72.0, |
| 1790 | [x, y], |
| 1791 | ) |
| 1792 | .unwrap() |
| 1793 | .snapshot(); |
| 1794 | let id = body.id; |
| 1795 | page.objects.push(PageObject::Outline(body)); |
| 1796 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); |
| 1797 | editor.select_all().unwrap(); |
| 1798 | editor.insert(&mut engine, text).unwrap(); |
| 1799 | let position = |editor: &CanvasEditor| { |
| 1800 | editor |
| 1801 | .outlines() |
| 1802 | .iter() |
| 1803 | .find(|outline| outline.id == id) |
| 1804 | .unwrap() |
| 1805 | .origin() |
| 1806 | }; |
| 1807 | assert_eq!(position(&editor)[0], x); |
| 1808 | assert!( |
| 1809 | (position(&editor)[1] - expected).abs() < 0.002, |
| 1810 | "({x}, {y}) after {text}: {:?}, expected {expected}", |
| 1811 | position(&editor) |
| 1812 | ); |
| 1813 | editor.select_all().unwrap(); |
| 1814 | editor.insert(&mut engine, "Header").unwrap(); |
| 1815 | assert!((position(&editor)[1] - expected).abs() < 0.002); |
| 1816 | editor.undo(&mut engine).unwrap(); |
| 1817 | editor.undo(&mut engine).unwrap(); |
| 1818 | assert_eq!(position(&editor), [x, y]); |
| 1819 | editor.redo(&mut engine).unwrap(); |
| 1820 | assert!((position(&editor)[1] - expected).abs() < 0.002); |
| 1821 | } |
| 1822 | for trigger in [true, false] { |
| 1823 | let mut page = Page::from_document(&document, "Baseline anchors").unwrap(); |
| 1824 | let image_bytes = page |
| 1825 | .objects |
| 1826 | .iter() |
| 1827 | .find_map(|object| match object { |
| 1828 | PageObject::Image(image) => image.bytes.clone(), |
| 1829 | _ => None, |
| 1830 | }) |
| 1831 | .unwrap(); |
| 1832 | page.objects |
| 1833 | .retain(|object| matches!(object, PageObject::Title(_))); |
| 1834 | for (x, y, background) in [ |
| 1835 | (120.0, 90.0, !trigger), |
| 1836 | (600.0, 150.0, false), |
| 1837 | (600.0, 90.0, true), |
| 1838 | (120.0, 60.0, false), |
| 1839 | ] { |
| 1840 | page.objects.push(PageObject::Image(Image { |
| 1841 | size: None, |
| 1842 | id: onestore::page::text::new_id().unwrap(), |
| 1843 | bytes: Some(image_bytes.clone()), |
| 1844 | display: None, |
| 1845 | layout: Layout { |
| 1846 | x: Some(x), |
| 1847 | y: Some(y), |
| 1848 | max_width: Some(72.0), |
| 1849 | max_height: Some(80.0), |
| 1850 | ..Default::default() |
| 1851 | }, |
| 1852 | alt: None, |
| 1853 | background, |
| 1854 | printout: None, |
| 1855 | tags: Vec::new(), |
| 1856 | link: None, |
| 1857 | text: None, |
| 1858 | })); |
| 1859 | } |
| 1860 | let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 1861 | scene.settle(Some(&editor), 1.0, Paper::WHITE); |
| 1862 | let images = |editor: &CanvasEditor| { |
| 1863 | let mut primitives = Vec::new(); |
| 1864 | scene |
| 1865 | .append_primitives_with::<SceneError>( |
| 1866 | &mut primitives, |
| 1867 | [0.0; 2], |
| 1868 | Some(editor), |
| 1869 | None, |
| 1870 | None, |
| 1871 | Paper::WHITE, |
| 1872 | |_, _, _| Ok(()), |
| 1873 | ) |
| 1874 | .unwrap(); |
| 1875 | primitives |
| 1876 | .into_iter() |
| 1877 | .filter_map(|primitive| match primitive { |
| 1878 | Primitive::Image { image, rect } => Some((image.id(), rect)), |
| 1879 | _ => None, |
| 1880 | }) |
| 1881 | .collect::<Vec<_>>() |
| 1882 | }; |
| 1883 | let before = images(&editor); |
| 1884 | editor.select_all().unwrap(); |
| 1885 | editor.insert(&mut engine, "Header").unwrap(); |
| 1886 | editor.select_all().unwrap(); |
| 1887 | editor.insert(&mut engine, long).unwrap(); |
| 1888 | let after = images(&editor); |
| 1889 | for (index, ((old_id, old), (new_id, new))) in before.iter().zip(&after).enumerate() { |
| 1890 | assert_eq!(old_id, new_id); |
| 1891 | let delta = if trigger && index < 2 { 26.18992 } else { 0.0 }; |
| 1892 | assert_eq!([old[0], old[2]], [new[0], new[2]]); |
| 1893 | assert!((old[1] + delta - new[1]).abs() < 0.002); |
| 1894 | assert!((old[3] + delta - new[3]).abs() < 0.002); |
| 1895 | assert!( |
| 1896 | scene |
| 1897 | .content_bounds(&editor, None) |
| 1898 | .any(|(bounds, _)| bounds == *new) |
| 1899 | ); |
| 1900 | } |
| 1901 | editor.undo(&mut engine).unwrap(); |
| 1902 | assert_eq!(images(&editor), before); |
| 1903 | editor.redo(&mut engine).unwrap(); |
| 1904 | assert_eq!(images(&editor), after); |
| 1905 | } |
| 1906 | } |
| 1907 | |
| 1908 | #[test] |
| 1909 | fn title_exit_places_an_uncommitted_body_caret_below_the_date() { |
| 1910 | use crate::document::TextDocument; |
| 1911 | use onestore::page::Title; |
| 1912 | let mut engine = TextEngine::default(); |
| 1913 | let fields = |
| 1914 | [("Header", 20.751953), ("Date\u{000b}Time", 12.207031)].map(|(text, line_spacing)| { |
| 1915 | let document = TextDocument::new(vec![Paragraph::new( |
| 1916 | text.into(), |
| 1917 | onestore::document::Format { |
| 1918 | font: Some("Arial".into()), |
| 1919 | font_size: Some(8.0), |
| 1920 | line_spacing: Some(line_spacing), |
| 1921 | ..Default::default() |
| 1922 | }, |
| 1923 | )]) |
| 1924 | .unwrap(); |
| 1925 | TextOutline::new(&mut engine, document, 468.0, [0.0; 2]) |
| 1926 | .unwrap() |
| 1927 | .snapshot() |
| 1928 | }); |
| 1929 | let mut fields = Vec::from(fields); |
| 1930 | fields[0].title = true; |
| 1931 | fields[0].min_width = Some(162.0); |
| 1932 | let title_id = fields[0].id; |
| 1933 | let page = Page { |
| 1934 | identity: None, |
| 1935 | created: None, |
| 1936 | title: "Header".into(), |
| 1937 | margin_origin: [36.0, 14.4], |
| 1938 | rtl: false, |
| 1939 | color: None, |
| 1940 | rule_lines: None, |
| 1941 | definitions: BTreeMap::new(), |
| 1942 | objects: vec![PageObject::Title(Title { |
| 1943 | date: None, |
| 1944 | id: ExGuid::default(), |
| 1945 | layout: Layout::default(), |
| 1946 | outlines: fields, |
| 1947 | })], |
| 1948 | }; |
| 1949 | let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); |
| 1950 | let original = editor.active_outline().document().clone(); |
| 1951 | editor.leave_title(&mut engine).unwrap(); |
| 1952 | assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, 86.4]); |
| 1953 | assert_eq!(editor.outlines().len(), 1); |
| 1954 | assert!(!editor.undo(&mut engine).unwrap()); |
| 1955 | editor.insert(&mut engine, "body").unwrap(); |
| 1956 | let body_id = editor.active_outline().id; |
| 1957 | editor.focus_outline(title_id).unwrap(); |
| 1958 | editor.leave_title(&mut engine).unwrap(); |
| 1959 | assert_eq!(editor.active_outline().id, body_id); |
| 1960 | assert!(editor.caret_outline().is_none()); |
| 1961 | editor.undo(&mut engine).unwrap(); |
| 1962 | assert_eq!(editor.outlines().len(), 1); |
| 1963 | editor.focus_outline(title_id).unwrap(); |
| 1964 | editor.select_all().unwrap(); |
| 1965 | editor |
| 1966 | .insert(&mut engine, "Header\u{000b}Second\u{000b}Third") |
| 1967 | .unwrap(); |
| 1968 | assert_eq!(editor.active_outline().document().nodes().len(), 1); |
| 1969 | assert_eq!( |
| 1970 | editor |
| 1971 | .active_outline() |
| 1972 | .layouts() |
| 1973 | .next() |
| 1974 | .unwrap() |
| 1975 | .1 |
| 1976 | .text |
| 1977 | .lines() |
| 1978 | .count(), |
| 1979 | 3 |
| 1980 | ); |
| 1981 | editor.leave_title(&mut engine).unwrap(); |
| 1982 | assert_eq!(editor.caret_outline().unwrap().origin(), [36.0, 140.4]); |
| 1983 | assert_eq!(editor.outlines().len(), 1); |
| 1984 | editor.undo(&mut engine).unwrap(); |
| 1985 | assert_eq!(editor.active_outline().id, title_id); |
| 1986 | assert_eq!(editor.active_outline().document(), &original); |
| 1987 | } |
| 1988 | |
| 1989 | #[test] |
| 1990 | fn reverse_hit_order_keeps_read_only_focus_indices() { |
| 1991 | let objects = [0.0, 20.0] |
| 1992 | .into_iter() |
| 1993 | .map(|position| { |
| 1994 | PageObject::Unsupported(onestore::page::Unsupported { |
| 1995 | id: ExGuid { |
| 1996 | guid: [position as u8; 16], |
| 1997 | n: 1, |
| 1998 | }, |
| 1999 | jcid: 0xdead, |
| 2000 | layout: Layout { |
| 2001 | x: Some(position), |
| 2002 | y: Some(position), |
| 2003 | max_width: Some(160.0), |
| 2004 | max_height: Some(80.0), |
| 2005 | ..Default::default() |
| 2006 | }, |
| 2007 | }) |
| 2008 | }) |
| 2009 | .collect(); |
| 2010 | let page = Page { |
| 2011 | identity: None, |
| 2012 | created: None, |
| 2013 | title: String::new(), |
| 2014 | margin_origin: [0.0; 2], |
| 2015 | rtl: false, |
| 2016 | color: None, |
| 2017 | rule_lines: None, |
| 2018 | definitions: BTreeMap::new(), |
| 2019 | objects, |
| 2020 | }; |
| 2021 | let scene = PageScene::new(page, &mut TextEngine::default()).unwrap(); |
| 2022 | assert_eq!( |
| 2023 | scene.hit_test::<()>([30.0, 30.0], None, |_| unreachable!()), |
| 2024 | Some(SceneHit::ReadOnly(1)) |
| 2025 | ); |
| 2026 | assert_eq!( |
| 2027 | scene.hit_test::<()>([5.0, 5.0], None, |_| unreachable!()), |
| 2028 | Some(SceneHit::ReadOnly(0)) |
| 2029 | ); |
| 2030 | assert_eq!( |
| 2031 | scene.hit_test::<()>([300.0, 300.0], None, |_| unreachable!()), |
| 2032 | None |
| 2033 | ); |
| 2034 | } |
| 2035 | |
| 2036 | #[test] |
| 2037 | fn unsupported_outlines_and_titles_leave_supported_outlines_editable() { |
| 2038 | use crate::document::TextDocument; |
| 2039 | use onestore::document::{Kind, Tag}; |
| 2040 | use onestore::page::{Definition, Outline, Title}; |
| 2041 | for title in [false, true] { |
| 2042 | let [tag, list] = [9, 10].map(|byte| ExGuid { |
| 2043 | guid: [byte; 16], |
| 2044 | n: 1, |
| 2045 | }); |
| 2046 | let make_outline = |text: &str| { |
| 2047 | let document = |
| 2048 | TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]) |
| 2049 | .unwrap(); |
| 2050 | Outline { |
| 2051 | title: false, |
| 2052 | min_width: None, |
| 2053 | id: ExGuid { |
| 2054 | guid: [u8::from(text == "Editable") + 1; 16], |
| 2055 | n: 1, |
| 2056 | }, |
| 2057 | layout: Layout { |
| 2058 | max_width: Some(200.0), |
| 2059 | ..Default::default() |
| 2060 | }, |
| 2061 | indents: vec![18.0, 0.0], |
| 2062 | paragraphs: document.nodes().to_vec(), |
| 2063 | unsupported: Vec::new(), |
| 2064 | } |
| 2065 | }; |
| 2066 | let mut numbered = make_outline("Numbered"); |
| 2067 | numbered.paragraphs[0].lists.push(list); |
| 2068 | let unsupported = if title { |
| 2069 | PageObject::Title(Title { |
| 2070 | date: None, |
| 2071 | id: ExGuid::default(), |
| 2072 | layout: Layout { |
| 2073 | x: Some(10.0), |
| 2074 | y: Some(20.0), |
| 2075 | ..Default::default() |
| 2076 | }, |
| 2077 | outlines: vec![numbered], |
| 2078 | }) |
| 2079 | } else { |
| 2080 | PageObject::Outline(numbered) |
| 2081 | }; |
| 2082 | // A tag symbol without artwork draws as a plain tag and stays editable. |
| 2083 | let mut editable = make_outline("Editable"); |
| 2084 | editable.paragraphs[0].tags.push(Tag { |
| 2085 | definition: Some(tag), |
| 2086 | status: 1, |
| 2087 | action_type: None, |
| 2088 | shape: None, |
| 2089 | property_status: None, |
| 2090 | created: None, |
| 2091 | completed: None, |
| 2092 | start: None, |
| 2093 | due: None, |
| 2094 | task_id: None, |
| 2095 | extra_set: 0, |
| 2096 | }); |
| 2097 | let page = Page { |
| 2098 | identity: None, |
| 2099 | created: None, |
| 2100 | title: String::new(), |
| 2101 | margin_origin: [36.0, 14.0], |
| 2102 | rtl: false, |
| 2103 | color: None, |
| 2104 | rule_lines: None, |
| 2105 | objects: vec![unsupported, PageObject::Outline(editable)], |
| 2106 | definitions: BTreeMap::from([ |
| 2107 | ( |
| 2108 | tag, |
| 2109 | Definition { |
| 2110 | kind: Kind::TagDefinition { |
| 2111 | shape: Some(999), |
| 2112 | label: Some("Unknown icon".into()), |
| 2113 | color: None, |
| 2114 | highlight: None, |
| 2115 | action_type: None, |
| 2116 | }, |
| 2117 | format: Default::default(), |
| 2118 | }, |
| 2119 | ), |
| 2120 | ( |
| 2121 | // A numbering sequence OneNote 2010 does not offer. |
| 2122 | list, |
| 2123 | Definition { |
| 2124 | kind: Kind::List { |
| 2125 | font: None, |
| 2126 | format: Some("\u{fffd}\u{63}.".into()), |
| 2127 | restart: None, |
| 2128 | bullet: None, |
| 2129 | }, |
| 2130 | format: Default::default(), |
| 2131 | }, |
| 2132 | ), |
| 2133 | ]), |
| 2134 | }; |
| 2135 | let mut engine = TextEngine::default(); |
| 2136 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 2137 | assert_eq!(editor.outlines().len(), 1); |
| 2138 | let readonly = scene.read_only(Some(&editor)).next().unwrap(); |
| 2139 | assert_eq!(scene.read_only(Some(&editor)).count(), 1); |
| 2140 | if title { |
| 2141 | assert_eq!(&readonly.rect()[..2], &[46.0, 34.0]); |
| 2142 | } |
| 2143 | editor.insert(&mut engine, "Still ").unwrap(); |
| 2144 | assert_eq!( |
| 2145 | editor |
| 2146 | .active_outline() |
| 2147 | .document() |
| 2148 | .paragraphs() |
| 2149 | .next() |
| 2150 | .unwrap() |
| 2151 | .text(), |
| 2152 | "Still Editable" |
| 2153 | ); |
| 2154 | editor.undo(&mut engine).unwrap(); |
| 2155 | assert_eq!( |
| 2156 | editor |
| 2157 | .active_outline() |
| 2158 | .document() |
| 2159 | .paragraphs() |
| 2160 | .next() |
| 2161 | .unwrap() |
| 2162 | .text(), |
| 2163 | "Editable" |
| 2164 | ); |
| 2165 | } |
| 2166 | } |
| 2167 | |
| 2168 | #[test] |
| 2169 | fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() { |
| 2170 | use crate::document::TextDocument; |
| 2171 | use onestore::page::{Outline, Unsupported}; |
| 2172 | let document = TextDocument::new(vec![Paragraph::new( |
| 2173 | "preserved text".into(), |
| 2174 | Default::default(), |
| 2175 | )]) |
| 2176 | .unwrap(); |
| 2177 | let original = document.nodes().to_vec(); |
| 2178 | let unknown = Unsupported { |
| 2179 | id: ExGuid { |
| 2180 | guid: [8; 16], |
| 2181 | n: 1, |
| 2182 | }, |
| 2183 | jcid: 0xdead, |
| 2184 | layout: Layout::default(), |
| 2185 | }; |
| 2186 | let page = Page { |
| 2187 | identity: None, |
| 2188 | created: None, |
| 2189 | title: String::new(), |
| 2190 | margin_origin: [0.0; 2], |
| 2191 | rtl: false, |
| 2192 | color: None, |
| 2193 | rule_lines: None, |
| 2194 | definitions: BTreeMap::new(), |
| 2195 | objects: vec![ |
| 2196 | PageObject::Outline(Outline { |
| 2197 | title: false, |
| 2198 | min_width: None, |
| 2199 | id: ExGuid { |
| 2200 | guid: [7; 16], |
| 2201 | n: 1, |
| 2202 | }, |
| 2203 | layout: Layout { |
| 2204 | x: Some(50.0), |
| 2205 | y: Some(70.0), |
| 2206 | max_width: Some(200.0), |
| 2207 | ..Default::default() |
| 2208 | }, |
| 2209 | indents: vec![18.0, 0.0], |
| 2210 | paragraphs: original.clone(), |
| 2211 | unsupported: vec![unknown.clone()], |
| 2212 | }), |
| 2213 | PageObject::Image(Image { |
| 2214 | size: None, |
| 2215 | id: ExGuid::default(), |
| 2216 | layout: Layout { |
| 2217 | y: Some(150.0), |
| 2218 | ..Default::default() |
| 2219 | }, |
| 2220 | bytes: None, |
| 2221 | display: None, |
| 2222 | alt: Some("missing diagram".into()), |
| 2223 | background: false, |
| 2224 | printout: None, |
| 2225 | tags: Vec::new(), |
| 2226 | link: None, |
| 2227 | text: None, |
| 2228 | }), |
| 2229 | PageObject::Unsupported(unknown.clone()), |
| 2230 | ], |
| 2231 | }; |
| 2232 | let mut engine = TextEngine::default(); |
| 2233 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 2234 | assert_eq!(scene.read_only(Some(&editor)).count(), 3); |
| 2235 | assert!(editor.outlines().is_empty()); |
| 2236 | assert!(editor.caret_outline().is_some()); |
| 2237 | assert!( |
| 2238 | editor.active_outline().origin()[0] |
| 2239 | > scene |
| 2240 | .read_only(Some(&editor)) |
| 2241 | .map(|object| object.rect()[2]) |
| 2242 | .fold(0.0_f32, f32::max) |
| 2243 | ); |
| 2244 | let labels: Vec<_> = scene |
| 2245 | .read_only(Some(&editor)) |
| 2246 | .map(|object| object.label.id()) |
| 2247 | .collect(); |
| 2248 | for edit in [true, false] { |
| 2249 | if edit { |
| 2250 | editor.insert(&mut engine, "new annotation").unwrap(); |
| 2251 | } else { |
| 2252 | editor.undo(&mut engine).unwrap(); |
| 2253 | } |
| 2254 | assert_eq!(editor.caret_outline().is_none(), edit); |
| 2255 | assert_eq!(editor.outlines().len(), usize::from(edit)); |
| 2256 | let readonly: Vec<_> = scene.read_only(Some(&editor)).collect(); |
| 2257 | let PageObject::Outline(outline) = &readonly[0].source else { |
| 2258 | panic!() |
| 2259 | }; |
| 2260 | assert_eq!(outline.paragraphs, original); |
| 2261 | assert_eq!( |
| 2262 | outline.unsupported.as_slice(), |
| 2263 | std::slice::from_ref(&unknown) |
| 2264 | ); |
| 2265 | let PageObject::Image(image) = &readonly[1].source else { |
| 2266 | panic!() |
| 2267 | }; |
| 2268 | assert_eq!(image.alt.as_deref(), Some("missing diagram")); |
| 2269 | assert!(image.bytes.is_none()); |
| 2270 | let PageObject::Unsupported(source) = &readonly[2].source else { |
| 2271 | panic!() |
| 2272 | }; |
| 2273 | assert_eq!(source, &unknown); |
| 2274 | assert_eq!( |
| 2275 | readonly |
| 2276 | .iter() |
| 2277 | .map(|object| object.label.id()) |
| 2278 | .collect::<Vec<_>>(), |
| 2279 | labels |
| 2280 | ); |
| 2281 | let primitives = paint_editor(&scene, &editor); |
| 2282 | assert_eq!(primitives.len(), 9); |
| 2283 | assert_eq!( |
| 2284 | primitives |
| 2285 | .iter() |
| 2286 | .filter(|p| matches!(p, Primitive::Text { .. })) |
| 2287 | .count(), |
| 2288 | 3 |
| 2289 | ); |
| 2290 | } |
| 2291 | assert_eq!( |
| 2292 | editor |
| 2293 | .active_outline() |
| 2294 | .document() |
| 2295 | .paragraphs() |
| 2296 | .next() |
| 2297 | .unwrap() |
| 2298 | .text(), |
| 2299 | "" |
| 2300 | ); |
| 2301 | } |
| 2302 | |
| 2303 | fn paint_editor<'a>(scene: &'a PageScene, editor: &'a CanvasEditor) -> Vec<Primitive<'a>> { |
| 2304 | let mut primitives = Vec::new(); |
| 2305 | scene |
| 2306 | .append_primitives_with( |
| 2307 | &mut primitives, |
| 2308 | [0.0; 2], |
| 2309 | Some(editor), |
| 2310 | None, |
| 2311 | None, |
| 2312 | Paper::WHITE, |
| 2313 | |id, offset, primitives| { |
| 2314 | let outline = editor |
| 2315 | .outlines() |
| 2316 | .iter() |
| 2317 | .find(|outline| outline.id == id) |
| 2318 | .ok_or(SceneError::MissingOutline)?; |
| 2319 | for (_, paragraph) in outline.layouts() { |
| 2320 | primitives.push(Primitive::Text { |
| 2321 | clip: None, |
| 2322 | text: &paragraph.text, |
| 2323 | ink: Paper::WHITE.ink, |
| 2324 | origin: [ |
| 2325 | outline.origin()[0] + paragraph.origin[0] + offset[0], |
| 2326 | outline.origin()[1] + paragraph.origin[1] + offset[1], |
| 2327 | ], |
| 2328 | }); |
| 2329 | } |
| 2330 | Ok::<_, SceneError>(()) |
| 2331 | }, |
| 2332 | ) |
| 2333 | .unwrap(); |
| 2334 | primitives |
| 2335 | } |
| 2336 | |
| 2337 | #[test] |
| 2338 | fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() { |
| 2339 | use crate::document::TextDocument; |
| 2340 | use onestore::page::Outline; |
| 2341 | use onestore::page::text::Paragraph; |
| 2342 | let outline = |x, text: &str| { |
| 2343 | let document = |
| 2344 | TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap(); |
| 2345 | PageObject::Outline(Outline { |
| 2346 | title: false, |
| 2347 | min_width: None, |
| 2348 | id: ExGuid { |
| 2349 | guid: [x as u8; 16], |
| 2350 | n: 1, |
| 2351 | }, |
| 2352 | layout: Layout { |
| 2353 | x: Some(x), |
| 2354 | max_width: Some(180.0), |
| 2355 | width_set_by_user: Some(true), |
| 2356 | ..Default::default() |
| 2357 | }, |
| 2358 | indents: vec![18.0, 0.0], |
| 2359 | paragraphs: document.nodes().to_vec(), |
| 2360 | unsupported: Vec::new(), |
| 2361 | }) |
| 2362 | }; |
| 2363 | let mut bytes = Vec::new(); |
| 2364 | { |
| 2365 | let mut encoder = png::Encoder::new(&mut bytes, 1, 1); |
| 2366 | encoder.set_color(png::ColorType::Rgba); |
| 2367 | encoder.set_depth(png::BitDepth::Eight); |
| 2368 | encoder |
| 2369 | .write_header() |
| 2370 | .unwrap() |
| 2371 | .write_image_data(&[17, 34, 51, 255]) |
| 2372 | .unwrap(); |
| 2373 | } |
| 2374 | let page = || Page { |
| 2375 | identity: None, |
| 2376 | created: None, |
| 2377 | title: String::new(), |
| 2378 | margin_origin: [0.0; 2], |
| 2379 | rtl: false, |
| 2380 | color: None, |
| 2381 | rule_lines: None, |
| 2382 | definitions: BTreeMap::new(), |
| 2383 | objects: vec![ |
| 2384 | outline(2.0, "first"), |
| 2385 | PageObject::Image(Image { |
| 2386 | size: None, |
| 2387 | id: ExGuid::default(), |
| 2388 | layout: Layout { |
| 2389 | x: Some(5.0), |
| 2390 | y: Some(7.0), |
| 2391 | max_width: Some(40.0), |
| 2392 | max_height: Some(30.0), |
| 2393 | ..Default::default() |
| 2394 | }, |
| 2395 | bytes: Some(Arc::from(bytes.clone())), |
| 2396 | display: None, |
| 2397 | alt: None, |
| 2398 | background: false, |
| 2399 | printout: None, |
| 2400 | tags: Vec::new(), |
| 2401 | link: None, |
| 2402 | text: None, |
| 2403 | }), |
| 2404 | outline(50.0, "last"), |
| 2405 | ], |
| 2406 | }; |
| 2407 | let mut engine = TextEngine::default(); |
| 2408 | let mut reference = PageScene::new(page(), &mut engine).unwrap(); |
| 2409 | let (mut scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); |
| 2410 | reference.settle(None, 1.0, Paper::WHITE); |
| 2411 | scene.settle(Some(&editor), 1.0, Paper::WHITE); |
| 2412 | let mut reference_primitives = Vec::new(); |
| 2413 | reference |
| 2414 | .append_primitives(&mut reference_primitives, [0.0; 2], Paper::WHITE) |
| 2415 | .unwrap(); |
| 2416 | let image_id; |
| 2417 | { |
| 2418 | let actual = paint_editor(&scene, &editor); |
| 2419 | assert_eq!(actual.len(), 3); |
| 2420 | for (actual, expected) in actual.iter().zip(&reference_primitives) { |
| 2421 | match (actual, expected) { |
| 2422 | ( |
| 2423 | Primitive::Text { |
| 2424 | clip: None, |
| 2425 | text: a, |
| 2426 | origin: x, |
| 2427 | .. |
| 2428 | }, |
| 2429 | Primitive::Text { |
| 2430 | clip: None, |
| 2431 | text: b, |
| 2432 | origin: y, |
| 2433 | .. |
| 2434 | }, |
| 2435 | ) => { |
| 2436 | assert_eq!(x, y); |
| 2437 | assert_eq!( |
| 2438 | painted_layout(*a) |
| 2439 | .lines() |
| 2440 | .next() |
| 2441 | .unwrap() |
| 2442 | .0 |
| 2443 | .metrics() |
| 2444 | .advance, |
| 2445 | painted_layout(*b) |
| 2446 | .lines() |
| 2447 | .next() |
| 2448 | .unwrap() |
| 2449 | .0 |
| 2450 | .metrics() |
| 2451 | .advance |
| 2452 | ); |
| 2453 | } |
| 2454 | ( |
| 2455 | Primitive::Image { image: a, rect: x }, |
| 2456 | Primitive::Image { image: b, rect: y }, |
| 2457 | ) => { |
| 2458 | assert_eq!(x, y); |
| 2459 | assert_eq!(a.pixels(), b.pixels()); |
| 2460 | } |
| 2461 | _ => panic!("paint order changed"), |
| 2462 | } |
| 2463 | } |
| 2464 | let Primitive::Image { image, .. } = &actual[1] else { |
| 2465 | panic!() |
| 2466 | }; |
| 2467 | image_id = image.id(); |
| 2468 | } |
| 2469 | let original = editor.active_outline().document().clone(); |
| 2470 | editor.insert(&mut engine, "new words ").unwrap(); |
| 2471 | { |
| 2472 | let actual = paint_editor(&scene, &editor); |
| 2473 | let Primitive::Text { text, .. } = &actual[0] else { |
| 2474 | panic!() |
| 2475 | }; |
| 2476 | let Primitive::Text { text: before, .. } = &reference_primitives[0] else { |
| 2477 | panic!() |
| 2478 | }; |
| 2479 | assert!( |
| 2480 | painted_layout(*text) |
| 2481 | .lines() |
| 2482 | .next() |
| 2483 | .unwrap() |
| 2484 | .0 |
| 2485 | .metrics() |
| 2486 | .advance |
| 2487 | > painted_layout(*before) |
| 2488 | .lines() |
| 2489 | .next() |
| 2490 | .unwrap() |
| 2491 | .0 |
| 2492 | .metrics() |
| 2493 | .advance |
| 2494 | ); |
| 2495 | let Primitive::Image { image, .. } = &actual[1] else { |
| 2496 | panic!() |
| 2497 | }; |
| 2498 | assert_eq!(image.id(), image_id); |
| 2499 | } |
| 2500 | editor.undo(&mut engine).unwrap(); |
| 2501 | assert_eq!(editor.active_outline().document(), &original); |
| 2502 | let actual = paint_editor(&scene, &editor); |
| 2503 | let Primitive::Text { text, .. } = &actual[0] else { |
| 2504 | panic!() |
| 2505 | }; |
| 2506 | let Primitive::Text { text: before, .. } = &reference_primitives[0] else { |
| 2507 | panic!() |
| 2508 | }; |
| 2509 | assert_eq!( |
| 2510 | painted_layout(*text) |
| 2511 | .lines() |
| 2512 | .next() |
| 2513 | .unwrap() |
| 2514 | .0 |
| 2515 | .metrics() |
| 2516 | .advance, |
| 2517 | painted_layout(*before) |
| 2518 | .lines() |
| 2519 | .next() |
| 2520 | .unwrap() |
| 2521 | .0 |
| 2522 | .metrics() |
| 2523 | .advance |
| 2524 | ); |
| 2525 | assert!(matches!( |
| 2526 | scene.append_primitives(&mut Vec::new(), [0.0; 2], Paper::WHITE), |
| 2527 | Err(SceneError::MissingOutline) |
| 2528 | )); |
| 2529 | } |
| 2530 | |
| 2531 | #[test] |
| 2532 | fn ink_draws_every_stroke_and_bounds_include_the_pen() { |
| 2533 | use onestore::page::{Ink, InkStroke}; |
| 2534 | let stroke = |points: Vec<[f32; 2]>, color| InkStroke { |
| 2535 | id: ExGuid::default(), |
| 2536 | points, |
| 2537 | width: 2.0, |
| 2538 | height: 2.0, |
| 2539 | color, |
| 2540 | transparency: Some(51), |
| 2541 | pen_tip: None, |
| 2542 | raster_operation: None, |
| 2543 | pressure: Vec::new(), |
| 2544 | }; |
| 2545 | let ink = Ink { |
| 2546 | id: ExGuid::default(), |
| 2547 | layout: Layout::default(), |
| 2548 | // A straight vertical line has no width of its own. |
| 2549 | strokes: vec![stroke(vec![[10.0, 20.0], [10.0, 20.1], [10.0, 60.0]], None)], |
| 2550 | groups: vec![Ink { |
| 2551 | id: ExGuid::default(), |
| 2552 | layout: Layout::default(), |
| 2553 | strokes: vec![stroke(vec![[40.0, 30.0]], Some(0x0000ff))], |
| 2554 | groups: Vec::new(), |
| 2555 | shape: None, |
| 2556 | }], |
| 2557 | shape: None, |
| 2558 | }; |
| 2559 | let page = Page { |
| 2560 | identity: None, |
| 2561 | created: None, |
| 2562 | title: String::new(), |
| 2563 | margin_origin: [0.0; 2], |
| 2564 | rtl: false, |
| 2565 | color: None, |
| 2566 | rule_lines: None, |
| 2567 | definitions: BTreeMap::new(), |
| 2568 | objects: vec![PageObject::Ink(ink.clone())], |
| 2569 | }; |
| 2570 | let mut engine = TextEngine::default(); |
| 2571 | let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 2572 | assert!( |
| 2573 | scene |
| 2574 | .content_bounds(&editor, None) |
| 2575 | .any(|(bounds, _)| bounds == [9.0, 19.0, 41.0, 61.0]) |
| 2576 | ); |
| 2577 | let mut primitives = Vec::new(); |
| 2578 | scene |
| 2579 | .append_primitives_with::<SceneError>( |
| 2580 | &mut primitives, |
| 2581 | [5.0, 0.0], |
| 2582 | Some(&editor), |
| 2583 | None, |
| 2584 | None, |
| 2585 | Paper::WHITE, |
| 2586 | |_, _, _| Ok(()), |
| 2587 | ) |
| 2588 | .unwrap(); |
| 2589 | let segments: Vec<_> = primitives |
| 2590 | .iter() |
| 2591 | .filter_map(|primitive| match primitive { |
| 2592 | Primitive::Segment { |
| 2593 | from, to, color, .. |
| 2594 | } => Some((*from, *to, *color)), |
| 2595 | _ => None, |
| 2596 | }) |
| 2597 | .collect(); |
| 2598 | let red = colorref(0x0000ff); |
| 2599 | assert_eq!( |
| 2600 | segments, |
| 2601 | [ |
| 2602 | ([15.0, 20.0], [15.0, 60.0], [0.0, 0.0, 0.0, 0.8]), |
| 2603 | ([45.0, 30.0], [45.0, 30.0], [red[0], red[1], red[2], 0.8]), |
| 2604 | ] |
| 2605 | ); |
| 2606 | assert_eq!(editor.page().unwrap().objects, [PageObject::Ink(ink)]); |
| 2607 | } |
| 2608 | |
| 2609 | #[test] |
| 2610 | fn pressure_widens_a_stroke_from_point_to_point_as_onenote_draws_it() { |
| 2611 | use onestore::page::{Ink, InkStroke}; |
| 2612 | let stroke = |points: Vec<[f32; 2]>, pressure: Vec<f32>| InkStroke { |
| 2613 | id: ExGuid::default(), |
| 2614 | points, |
| 2615 | width: 4.0, |
| 2616 | height: 4.0, |
| 2617 | color: None, |
| 2618 | transparency: None, |
| 2619 | pen_tip: None, |
| 2620 | raster_operation: None, |
| 2621 | pressure, |
| 2622 | }; |
| 2623 | let ink = Ink { |
| 2624 | id: ExGuid::default(), |
| 2625 | layout: Layout::default(), |
| 2626 | strokes: vec![ |
| 2627 | stroke( |
| 2628 | vec![[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]], |
| 2629 | vec![0.0, 0.5, 1.0], |
| 2630 | ), |
| 2631 | // A straight run at one pressure is one segment. |
| 2632 | stroke(vec![[0.0, 10.0], [10.0, 10.0], [20.0, 10.0]], vec![0.0; 3]), |
| 2633 | ], |
| 2634 | groups: Vec::new(), |
| 2635 | shape: None, |
| 2636 | }; |
| 2637 | let mut primitives = Vec::new(); |
| 2638 | append_ink(&ink, [0.0; 2], [0.0, 0.0, 0.0, 1.0], &mut primitives); |
| 2639 | let found: Vec<([f32; 2], [f32; 2], [f32; 2])> = primitives |
| 2640 | .iter() |
| 2641 | .map(|primitive| match primitive { |
| 2642 | Primitive::Taper { |
| 2643 | from, to, widths, .. |
| 2644 | } => (*from, *to, *widths), |
| 2645 | Primitive::Segment { |
| 2646 | from, to, width, .. |
| 2647 | } => (*from, *to, [*width; 2]), |
| 2648 | _ => panic!(), |
| 2649 | }) |
| 2650 | .collect(); |
| 2651 | // A quarter of the pen at no pressure, all of it at half and 1.75 times at full. |
| 2652 | assert_eq!( |
| 2653 | found, |
| 2654 | [ |
| 2655 | ([0.0, 0.0], [10.0, 0.0], [1.0, 4.0]), |
| 2656 | ([10.0, 0.0], [20.0, 0.0], [4.0, 7.0]), |
| 2657 | ([0.0, 10.0], [20.0, 10.0], [1.0, 1.0]), |
| 2658 | ] |
| 2659 | ); |
| 2660 | } |
| 2661 | |
| 2662 | #[test] |
| 2663 | fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { |
| 2664 | let mut encoded = Vec::new(); |
| 2665 | { |
| 2666 | let mut encoder = png::Encoder::new(&mut encoded, 1, 1); |
| 2667 | encoder.set_color(png::ColorType::Rgba); |
| 2668 | encoder.set_depth(png::BitDepth::Eight); |
| 2669 | encoder |
| 2670 | .write_header() |
| 2671 | .unwrap() |
| 2672 | .write_image_data(&[255, 0, 0, 255]) |
| 2673 | .unwrap(); |
| 2674 | } |
| 2675 | let page = |width| Page { |
| 2676 | identity: None, |
| 2677 | created: None, |
| 2678 | title: String::new(), |
| 2679 | margin_origin: [0.0; 2], |
| 2680 | rtl: false, |
| 2681 | color: None, |
| 2682 | rule_lines: None, |
| 2683 | definitions: BTreeMap::new(), |
| 2684 | objects: vec![PageObject::Image(Image { |
| 2685 | size: None, |
| 2686 | id: ExGuid::default(), |
| 2687 | layout: Layout { |
| 2688 | x: Some(2.0), |
| 2689 | y: Some(5.0), |
| 2690 | max_width: Some(width), |
| 2691 | max_height: Some(4.0), |
| 2692 | ..Default::default() |
| 2693 | }, |
| 2694 | bytes: Some(Arc::from(encoded.clone())), |
| 2695 | display: None, |
| 2696 | alt: None, |
| 2697 | background: false, |
| 2698 | printout: None, |
| 2699 | tags: Vec::new(), |
| 2700 | link: None, |
| 2701 | text: None, |
| 2702 | })], |
| 2703 | }; |
| 2704 | let mut engine = TextEngine::default(); |
| 2705 | let mut scene = PageScene::new(page(3.0), &mut engine).unwrap(); |
| 2706 | scene.settle(None, 1.0, Paper::WHITE); |
| 2707 | for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] { |
| 2708 | assert!(matches!( |
| 2709 | PageScene::new(page(invalid), &mut engine), |
| 2710 | Err(SceneError::InvalidGeometry) |
| 2711 | )); |
| 2712 | } |
| 2713 | let mut primitives = Vec::new(); |
| 2714 | scene |
| 2715 | .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) |
| 2716 | .unwrap(); |
| 2717 | scene |
| 2718 | .append_primitives(&mut primitives, [10.0, -20.0], Paper::WHITE) |
| 2719 | .unwrap(); |
| 2720 | let [ |
| 2721 | Primitive::Image { |
| 2722 | image: first, |
| 2723 | rect: a, |
| 2724 | }, |
| 2725 | Primitive::Image { |
| 2726 | image: second, |
| 2727 | rect: b, |
| 2728 | }, |
| 2729 | ] = primitives.as_slice() |
| 2730 | else { |
| 2731 | panic!() |
| 2732 | }; |
| 2733 | assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]); |
| 2734 | assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]); |
| 2735 | assert_eq!(first.id(), second.id()); |
| 2736 | assert_eq!(first.pixels(), [255, 0, 0, 255]); |
| 2737 | } |
| 2738 | |
| 2739 | #[test] |
| 2740 | fn foreground_pictures_take_hits_and_draw_at_their_moving_preview() { |
| 2741 | let mut bytes = Vec::new(); |
| 2742 | { |
| 2743 | let mut encoder = png::Encoder::new(&mut bytes, 1, 1); |
| 2744 | encoder.set_color(png::ColorType::Rgba); |
| 2745 | encoder.set_depth(png::BitDepth::Eight); |
| 2746 | encoder |
| 2747 | .write_header() |
| 2748 | .unwrap() |
| 2749 | .write_image_data(&[17, 34, 51, 255]) |
| 2750 | .unwrap(); |
| 2751 | } |
| 2752 | let image = |x: f32, background| Image { |
| 2753 | size: None, |
| 2754 | id: onestore::page::text::new_id().unwrap(), |
| 2755 | layout: Layout { |
| 2756 | x: Some(x), |
| 2757 | y: Some(0.0), |
| 2758 | max_width: Some(100.0), |
| 2759 | max_height: Some(100.0), |
| 2760 | ..Default::default() |
| 2761 | }, |
| 2762 | bytes: Some(Arc::from(bytes.clone())), |
| 2763 | display: None, |
| 2764 | alt: None, |
| 2765 | background, |
| 2766 | printout: None, |
| 2767 | tags: Vec::new(), |
| 2768 | link: None, |
| 2769 | text: None, |
| 2770 | }; |
| 2771 | let [background, picture] = [image(0.0, true), image(50.0, false)]; |
| 2772 | let id = picture.id; |
| 2773 | let page = Page { |
| 2774 | identity: None, |
| 2775 | created: None, |
| 2776 | title: String::new(), |
| 2777 | margin_origin: [0.0; 2], |
| 2778 | rtl: false, |
| 2779 | color: None, |
| 2780 | rule_lines: None, |
| 2781 | definitions: BTreeMap::new(), |
| 2782 | objects: vec![PageObject::Image(background), PageObject::Image(picture)], |
| 2783 | }; |
| 2784 | let mut engine = TextEngine::default(); |
| 2785 | let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 2786 | scene.settle(Some(&editor), 1.0, Paper::WHITE); |
| 2787 | let hit = |x| scene.hit_test::<()>([x, 10.0], Some(&editor), |_| None); |
| 2788 | assert_eq!(hit(60.0), Some(SceneHit::Image(id))); |
| 2789 | assert_eq!(hit(10.0), None); |
| 2790 | let mut primitives = Vec::new(); |
| 2791 | scene |
| 2792 | .append_primitives_with::<SceneError>( |
| 2793 | &mut primitives, |
| 2794 | [0.0; 2], |
| 2795 | Some(&editor), |
| 2796 | None, |
| 2797 | Some((id, [70.0, 5.0, 120.0, 55.0])), |
| 2798 | Paper::WHITE, |
| 2799 | |_, _, _| Ok(()), |
| 2800 | ) |
| 2801 | .unwrap(); |
| 2802 | let rects: Vec<_> = primitives |
| 2803 | .iter() |
| 2804 | .filter_map(|primitive| match primitive { |
| 2805 | Primitive::Image { rect, .. } => Some(*rect), |
| 2806 | _ => None, |
| 2807 | }) |
| 2808 | .collect(); |
| 2809 | assert_eq!(rects, [[0.0, 0.0, 100.0, 100.0], [70.0, 5.0, 120.0, 55.0]]); |
| 2810 | } |
| 2811 | |
| 2812 | #[test] |
| 2813 | fn pictures_past_the_budget_open_decode_in_view_and_keep_their_bytes() { |
| 2814 | let side = 1024; |
| 2815 | let mut bytes = Vec::new(); |
| 2816 | { |
| 2817 | let mut encoder = png::Encoder::new(&mut bytes, side, side); |
| 2818 | encoder.set_color(png::ColorType::Rgba); |
| 2819 | encoder.set_depth(png::BitDepth::Eight); |
| 2820 | encoder |
| 2821 | .write_header() |
| 2822 | .unwrap() |
| 2823 | .write_image_data(&[40, 90, 160, 255].repeat((side * side) as usize)) |
| 2824 | .unwrap(); |
| 2825 | } |
| 2826 | let bytes: Arc<[u8]> = Arc::from(bytes); |
| 2827 | let pictures: Vec<_> = (0..20) |
| 2828 | .map(|index| Image { |
| 2829 | size: None, |
| 2830 | id: onestore::page::text::new_id().unwrap(), |
| 2831 | layout: Layout { |
| 2832 | x: Some(0.0), |
| 2833 | y: Some(index as f32 * 600.0), |
| 2834 | max_width: Some(512.0), |
| 2835 | max_height: Some(512.0), |
| 2836 | ..Default::default() |
| 2837 | }, |
| 2838 | bytes: Some(Arc::clone(&bytes)), |
| 2839 | display: None, |
| 2840 | alt: None, |
| 2841 | background: false, |
| 2842 | printout: None, |
| 2843 | tags: Vec::new(), |
| 2844 | link: None, |
| 2845 | text: None, |
| 2846 | }) |
| 2847 | .collect(); |
| 2848 | assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES); |
| 2849 | let page = Page { |
| 2850 | identity: None, |
| 2851 | created: None, |
| 2852 | title: String::new(), |
| 2853 | margin_origin: [0.0; 2], |
| 2854 | rtl: false, |
| 2855 | color: None, |
| 2856 | rule_lines: None, |
| 2857 | definitions: BTreeMap::new(), |
| 2858 | objects: pictures.iter().cloned().map(PageObject::Image).collect(), |
| 2859 | }; |
| 2860 | let mut engine = TextEngine::default(); |
| 2861 | let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); |
| 2862 | let kept = |scene: &PageScene| -> u64 { |
| 2863 | let shown = scene.pictures.values().filter_map(Picture::image); |
| 2864 | shown.map(|image| image.pixels().len() as u64).sum() |
| 2865 | }; |
| 2866 | let shown = |scene: &PageScene, index: usize| { |
| 2867 | scene.image(pictures[index].id).map(|image| image.size()) |
| 2868 | }; |
| 2869 | let settle = |scene: &mut PageScene, view: [f32; 4]| { |
| 2870 | let give_up = Instant::now() + Duration::from_secs(20); |
| 2871 | while !scene.update_pictures( |
| 2872 | Some(&editor), |
| 2873 | None, |
| 2874 | view, |
| 2875 | 2.0, |
| 2876 | Paper::WHITE, |
| 2877 | Waker::noop(), |
| 2878 | ) { |
| 2879 | assert!( |
| 2880 | Instant::now() < give_up, |
| 2881 | "the pictures in view never landed" |
| 2882 | ); |
| 2883 | std::thread::sleep(Duration::from_millis(2)); |
| 2884 | } |
| 2885 | assert!(kept(scene) <= picture::BUDGET, "{}", kept(scene)); |
| 2886 | }; |
| 2887 | for top in (0..20).map(|index| index as f32 * 600.0) { |
| 2888 | let view = [0.0, top, 600.0, top + 1200.0]; |
| 2889 | settle(&mut scene, view); |
| 2890 | let index = (top / 600.0) as usize; |
| 2891 | assert_eq!(shown(&scene, index), Some([side; 2])); |
| 2892 | let mut primitives = Vec::new(); |
| 2893 | scene |
| 2894 | .append_primitives_with::<SceneError>( |
| 2895 | &mut primitives, |
| 2896 | [0.0; 2], |
| 2897 | Some(&editor), |
| 2898 | None, |
| 2899 | None, |
| 2900 | Paper::WHITE, |
| 2901 | |_, _, _| Ok(()), |
| 2902 | ) |
| 2903 | .unwrap(); |
| 2904 | assert!(primitives.iter().any(|primitive| matches!( |
| 2905 | primitive, |
| 2906 | Primitive::Image { rect, .. } if rect[1] == top |
| 2907 | ))); |
| 2908 | } |
| 2909 | assert_eq!(shown(&scene, 0), None, "the first picture was let go"); |
| 2910 | // Every picture in view at once shrinks to fit rather than failing the frame. |
| 2911 | settle(&mut scene, [0.0, 0.0, 600.0, 12_000.0]); |
| 2912 | for index in 0..20 { |
| 2913 | let [width, height] = shown(&scene, index).unwrap(); |
| 2914 | assert!(width < side && width == height, "{width}×{height}"); |
| 2915 | } |
| 2916 | for object in editor.page().unwrap().objects { |
| 2917 | let PageObject::Image(picture) = object else { |
| 2918 | panic!() |
| 2919 | }; |
| 2920 | assert!(Arc::ptr_eq(picture.bytes.as_ref().unwrap(), &bytes)); |
| 2921 | } |
| 2922 | } |
| 2923 | } |