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