| 1 | //! Reading view: a page's content in reading order, reflowed into one column as wide as a |
| 2 | //! phone's screen. The reflowed page is only shown; nothing of it is stored. |
| 3 | //! |
| 4 | //! Blocks are the page's outlines, pictures, files and drawings, as laid out. Blocks whose |
| 5 | //! lines or pictures overlap, ink over text, and strokes within reach of each other join |
| 6 | //! into one piece moved whole. Reading order is a recursive XY cut: rows split by horizontal |
| 7 | //! gaps, then columns by vertical gaps, left column first. Notes beside one long outline are |
| 8 | //! asides: the outline splits after the paragraph each note sits beside, and the note follows |
| 9 | //! it. Text rewraps to the column, pictures shrink to it, and everything else keeps its size. |
| 10 | |
| 11 | use crate::{ |
| 12 | editor::{CanvasEditor, EditorError}, |
| 13 | layout::TextEngine, |
| 14 | }; |
| 15 | use onestore::{ |
| 16 | ExGuid, |
| 17 | page::{Outline, Page, PageObject, ParagraphContent}, |
| 18 | }; |
| 19 | use std::{collections::BTreeMap, ops::Range}; |
| 20 | |
| 21 | #[cfg(feature = "gpu")] |
| 22 | use crate::editor::TextOutline; |
| 23 | #[cfg(feature = "interaction")] |
| 24 | use crate::editor::page::Content; |
| 25 | |
| 26 | #[cfg(feature = "gpu")] |
| 27 | pub(crate) struct Column { |
| 28 | #[cfg(feature = "interaction")] |
| 29 | pub(crate) width: f32, |
| 30 | pub(crate) outlines: Vec<(ExGuid, [f32; 2], crate::outline::OutlineLayout)>, |
| 31 | /// Page pictures as the column shows them, `[x0, y0, x1, y1]` in page points. |
| 32 | pub(crate) pictures: Vec<(ExGuid, [f32; 4])>, |
| 33 | /// Where the note that the page has drawings, which the column leaves out, starts. |
| 34 | pub(crate) drawings: Option<([f32; 2], crate::layout::TextLayout)>, |
| 35 | } |
| 36 | |
| 37 | #[cfg(feature = "gpu")] |
| 38 | impl Column { |
| 39 | /// The note's `[x0, y0, x1, y1]` in page points. |
| 40 | pub(crate) fn drawings_rect(&self) -> Option<[f32; 4]> { |
| 41 | let ([x, y], note) = self.drawings.as_ref()?; |
| 42 | Some([*x, *y, x + note.shaped.width(), y + note.height()]) |
| 43 | } |
| 44 | |
| 45 | /// Where the column shows page picture `id`; None for one it leaves out. |
| 46 | pub(crate) fn picture(&self, id: ExGuid) -> Option<[f32; 4]> { |
| 47 | self.pictures |
| 48 | .iter() |
| 49 | .find_map(|(picture, rect)| (*picture == id).then_some(*rect)) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | #[cfg(feature = "gpu")] |
| 54 | pub(crate) fn geometry<'a>( |
| 55 | source: &'a TextOutline, |
| 56 | column: Option<&'a Column>, |
| 57 | ) -> (crate::editor::OutlineGeometry<'a>, [f32; 2]) { |
| 58 | let (shaped, origin) = match column { |
| 59 | Some(column) => { |
| 60 | let (_, origin, shaped) = column |
| 61 | .outlines |
| 62 | .iter() |
| 63 | .find(|(id, _, _)| *id == source.id) |
| 64 | .expect("active column contains every current outline"); |
| 65 | (shaped, *origin) |
| 66 | } |
| 67 | None => (source.shaped(), source.origin()), |
| 68 | }; |
| 69 | (crate::editor::OutlineGeometry { source, shaped }, origin) |
| 70 | } |
| 71 | |
| 72 | #[cfg(feature = "interaction")] |
| 73 | impl Column { |
| 74 | /// The page's editable outlines and pictures stacked in reading order, title and its |
| 75 | /// header first, or None where something on the page would not reflow. |
| 76 | pub(crate) fn build( |
| 77 | editor: &CanvasEditor, |
| 78 | scene: Option<&crate::gpu::page::PageScene>, |
| 79 | width: f32, |
| 80 | engine: &mut TextEngine, |
| 81 | ) -> Result<Option<Self>, crate::layout::LayoutError> { |
| 82 | if !width.is_finite() || width <= 0.0 { |
| 83 | return Err(crate::layout::LayoutError::InvalidWidth); |
| 84 | } |
| 85 | if editor.rtl() { |
| 86 | return Ok(None); |
| 87 | } |
| 88 | let (titles, body): (Vec<_>, Vec<_>) = editor |
| 89 | .visible_outlines() |
| 90 | .chain(editor.caret_outline()) |
| 91 | .partition(|outline| outline.title); |
| 92 | let title = match titles[..] { |
| 93 | [] => None, |
| 94 | [title] => Some(title), |
| 95 | _ => return Ok(None), |
| 96 | }; |
| 97 | if editor.objects.iter().any(|object| { |
| 98 | if let Content::Date { below_title } | Content::Outline { below_title, .. } = object |
| 99 | && (below_title.is_none() || *below_title != title.map(|title| title.id)) |
| 100 | { |
| 101 | return true; |
| 102 | } |
| 103 | if let Content::Outline { source, .. } = object |
| 104 | && source |
| 105 | .paragraphs |
| 106 | .iter() |
| 107 | .any(|paragraph| !matches!(paragraph.content, ParagraphContent::Text(_))) |
| 108 | { |
| 109 | return true; |
| 110 | } |
| 111 | !matches!( |
| 112 | object, |
| 113 | Content::Editable(_) |
| 114 | | Content::Date { .. } |
| 115 | | Content::Outline { |
| 116 | below_title: Some(_), |
| 117 | .. |
| 118 | } |
| 119 | | Content::Image(_) |
| 120 | | Content::Ink(_) |
| 121 | ) |
| 122 | }) { |
| 123 | return Ok(None); |
| 124 | } |
| 125 | if scene.is_none() |
| 126 | && editor |
| 127 | .objects |
| 128 | .iter() |
| 129 | .any(|object| !matches!(object, Content::Editable(_))) |
| 130 | { |
| 131 | return Ok(None); |
| 132 | } |
| 133 | let tops = editor |
| 134 | .outlines() |
| 135 | .iter() |
| 136 | .map(|outline| (outline.id, root_tops(outline))) |
| 137 | .collect(); |
| 138 | // Emptied outlines, and one being started, keep their place while typing. |
| 139 | let blocks = blocks( |
| 140 | editor, |
| 141 | editor.visible_outlines().chain(editor.caret_outline()), |
| 142 | ); |
| 143 | if drawn_or_arranged(&blocks).is_some() { |
| 144 | return Ok(None); |
| 145 | } |
| 146 | let ordered = order(blocks, &tops, &mut Vec::new()); |
| 147 | let rank = |id: ExGuid| { |
| 148 | ordered |
| 149 | .iter() |
| 150 | .position(|block| block.members.contains(&id)) |
| 151 | .unwrap_or(ordered.len()) |
| 152 | }; |
| 153 | enum Piece<'a> { |
| 154 | Text(&'a TextOutline), |
| 155 | Picture(ExGuid, [f32; 2], [f32; 2]), |
| 156 | } |
| 157 | let pictures = editor.objects.iter().filter_map(|object| match object { |
| 158 | Content::Image(image) if !image.background => Some(Piece::Picture( |
| 159 | image.id, |
| 160 | crate::origin(&image.layout), |
| 161 | crate::outline::image_size(image)?, |
| 162 | )), |
| 163 | _ => None, |
| 164 | }); |
| 165 | let mut pieces: Vec<_> = body |
| 166 | .into_iter() |
| 167 | .map(Piece::Text) |
| 168 | .chain(pictures) |
| 169 | .map(|piece| { |
| 170 | let (id, origin) = match piece { |
| 171 | Piece::Text(outline) => (outline.id, outline.origin()), |
| 172 | Piece::Picture(id, origin, _) => (id, origin), |
| 173 | }; |
| 174 | ((rank(id), origin), piece) |
| 175 | }) |
| 176 | .collect(); |
| 177 | pieces.sort_by(|((a_rank, [ax, ay]), _), ((b_rank, [bx, by]), _)| { |
| 178 | a_rank |
| 179 | .cmp(b_rank) |
| 180 | .then(ay.total_cmp(by)) |
| 181 | .then(ax.total_cmp(bx)) |
| 182 | }); |
| 183 | let mut column = Self { |
| 184 | width, |
| 185 | outlines: Vec::new(), |
| 186 | pictures: Vec::new(), |
| 187 | drawings: None, |
| 188 | }; |
| 189 | let mut top = 14.4; |
| 190 | if let Some(title) = title { |
| 191 | let Some(layout) = column_layout(title, editor, width, engine)? else { |
| 192 | return Ok(None); |
| 193 | }; |
| 194 | top += layout.size[1] + GAP; |
| 195 | column.outlines.push((title.id, [36.0, 14.4], layout)); |
| 196 | } |
| 197 | if let Some(scene) = scene { |
| 198 | for (rect, _) in scene.content_bounds(editor, Some(&column)) { |
| 199 | if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 { |
| 200 | return Ok(None); |
| 201 | } |
| 202 | top = top.max(rect[3] + GAP); |
| 203 | } |
| 204 | } |
| 205 | for (_, piece) in pieces { |
| 206 | let height = match piece { |
| 207 | Piece::Text(source) => { |
| 208 | let Some(layout) = column_layout(source, editor, width, engine)? else { |
| 209 | return Ok(None); |
| 210 | }; |
| 211 | let height = layout.size[1]; |
| 212 | column.outlines.push((source.id, [36.0, top], layout)); |
| 213 | height |
| 214 | } |
| 215 | Piece::Picture(id, _, [w, h]) => { |
| 216 | let scale = (width / w).min(1.0); |
| 217 | column |
| 218 | .pictures |
| 219 | .push((id, [36.0, top, 36.0 + w * scale, top + h * scale])); |
| 220 | h * scale |
| 221 | } |
| 222 | }; |
| 223 | top += height + GAP; |
| 224 | } |
| 225 | if editor |
| 226 | .objects |
| 227 | .iter() |
| 228 | .any(|object| matches!(object, Content::Ink(_))) |
| 229 | { |
| 230 | let note = engine.layout( |
| 231 | &onestore::page::text::Paragraph::new( |
| 232 | DRAWINGS_NOTE.into(), |
| 233 | onestore::document::Format { |
| 234 | font_size: Some(10.0), |
| 235 | ..Default::default() |
| 236 | }, |
| 237 | ), |
| 238 | width, |
| 239 | )?; |
| 240 | column.drawings = Some(([36.0, top], note)); |
| 241 | } |
| 242 | Ok(Some(column)) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | /// `source` wrapped to a column `width` points wide, or None where it would not fit. |
| 247 | #[cfg(feature = "interaction")] |
| 248 | fn column_layout( |
| 249 | source: &TextOutline, |
| 250 | editor: &CanvasEditor, |
| 251 | width: f32, |
| 252 | engine: &mut TextEngine, |
| 253 | ) -> Result<Option<crate::outline::OutlineLayout>, crate::layout::LayoutError> { |
| 254 | use crate::outline::{OutlineLayout, ParagraphLayout}; |
| 255 | if source.document().paragraphs().any(|text| { |
| 256 | text.spans() |
| 257 | .iter() |
| 258 | .any(|span| span.format.math == Some(true) || span.format.math_object.is_some()) |
| 259 | }) { |
| 260 | return Ok(None); |
| 261 | } |
| 262 | // Pictures in the flow shrink to the room their level leaves. |
| 263 | let mut nodes = source.document().nodes().to_vec(); |
| 264 | for node in &mut nodes { |
| 265 | match &mut node.content { |
| 266 | ParagraphContent::Text(_) | ParagraphContent::Ink(_) => {} |
| 267 | ParagraphContent::Image(image) => { |
| 268 | let indent = crate::outline::indentation(node.level, &source.indents, width)?; |
| 269 | shrink(image, width - indent); |
| 270 | } |
| 271 | _ => return Ok(None), |
| 272 | } |
| 273 | } |
| 274 | let layout = OutlineLayout::flow( |
| 275 | nodes.iter(), |
| 276 | &source.indents, |
| 277 | width, |
| 278 | false, |
| 279 | 0, |
| 280 | None, |
| 281 | &mut |node, previous, width, indents| { |
| 282 | ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions) |
| 283 | }, |
| 284 | )?; |
| 285 | let fits = layout.size[0] <= width + 1.0 |
| 286 | && layout.paragraphs.iter().all(|paragraph| { |
| 287 | paragraph.origin[0] >= 0.0 |
| 288 | && paragraph.origin[0] |
| 289 | + paragraph |
| 290 | .text |
| 291 | .lines() |
| 292 | .map(|(line, _)| { |
| 293 | line.metrics().advance - line.metrics().trailing_whitespace |
| 294 | }) |
| 295 | .fold(0.0_f32, f32::max) |
| 296 | <= width + 1.0 |
| 297 | }); |
| 298 | Ok(fits.then_some(layout)) |
| 299 | } |
| 300 | |
| 301 | /// Ends a column that leaves out the page's drawings. |
| 302 | pub(crate) const DRAWINGS_NOTE: &str = "This page has drawings. Turn off Reading View to see them."; |
| 303 | |
| 304 | /// Points between blocks in the column: OneNote's grid row. |
| 305 | const GAP: f32 = 18.0; |
| 306 | /// Strokes this near one another are one drawing, as handwriting is many strokes. |
| 307 | const INK_REACH: f32 = 9.0; |
| 308 | /// The share of the content's area drawn, or kept as laid out, past which the page is a |
| 309 | /// canvas rather than a document. |
| 310 | const CANVAS_SHARE: f64 = 0.5; |
| 311 | /// How far below a paragraph's top a note beside it may start and still follow it. |
| 312 | const ASIDE_SLACK: f32 = 6.0; |
| 313 | /// How far an aside sits in from the column's edge, and the room above and below it. |
| 314 | const ASIDE_INDENT: f32 = 18.0; |
| 315 | const ASIDE_GAP: f32 = 6.0; |
| 316 | |
| 317 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 318 | pub enum Kind { |
| 319 | Text, |
| 320 | Picture, |
| 321 | File, |
| 322 | Drawing, |
| 323 | /// Overlapping blocks or ink over text, moved as one piece. |
| 324 | Group, |
| 325 | } |
| 326 | |
| 327 | #[derive(Clone, Debug, PartialEq)] |
| 328 | pub struct Block { |
| 329 | pub kind: Kind, |
| 330 | /// The page objects it moves. |
| 331 | pub members: Vec<ExGuid>, |
| 332 | /// `[x0, y0, x1, y1]` in page points, as laid out on the page. |
| 333 | pub bounds: [f32; 4], |
| 334 | /// The outline's top-level paragraphs it shows, where asides split the outline. |
| 335 | pub part: Option<Range<usize>>, |
| 336 | /// A note beside an outline, read after the paragraph it sits by. |
| 337 | pub aside: bool, |
| 338 | } |
| 339 | |
| 340 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 341 | pub enum Verdict { |
| 342 | /// No wider than the column already: shown as laid out. |
| 343 | Fits, |
| 344 | /// Every block flows into the column. |
| 345 | Reflows, |
| 346 | /// Flows, with what each `Kept` names read in an order or shape the page decides. |
| 347 | Partial(Vec<Kept>), |
| 348 | /// Not offered. |
| 349 | Refused(Refusal), |
| 350 | } |
| 351 | |
| 352 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 353 | pub enum Kept { |
| 354 | /// Notes beside an outline, each placed after the paragraph it sits beside. |
| 355 | Asides, |
| 356 | /// Blocks side by side, or interlocked, read left column first. |
| 357 | SideBySide, |
| 358 | /// Overlapping blocks or ink over text, moved whole at their size. |
| 359 | Group, |
| 360 | /// Something still wider than the column: a table with set column widths, a drawing, a |
| 361 | /// group. |
| 362 | Wide, |
| 363 | } |
| 364 | |
| 365 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 366 | pub enum Refusal { |
| 367 | /// Its positions read from the right; not yet reflowed. |
| 368 | RightToLeft, |
| 369 | /// Mostly ink. |
| 370 | Drawn, |
| 371 | /// Mostly overlapping blocks or ink over text. |
| 372 | Arranged, |
| 373 | } |
| 374 | |
| 375 | pub struct Reading { |
| 376 | pub verdict: Verdict, |
| 377 | /// In reading order where the page reflows, else as found. |
| 378 | pub blocks: Vec<Block>, |
| 379 | /// The page reflowed, where it reflows. |
| 380 | pub page: Option<Page>, |
| 381 | } |
| 382 | |
| 383 | impl Verdict { |
| 384 | /// Whether the page has a reading view. |
| 385 | pub fn offered(&self) -> bool { |
| 386 | matches!(self, Self::Reflows | Self::Partial(_)) |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | /// `page` read into a column `column` points wide. |
| 391 | pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading, EditorError> { |
| 392 | let editor = CanvasEditor::from_page(page.clone(), engine)?; |
| 393 | let blocks = blocks( |
| 394 | &editor, |
| 395 | editor |
| 396 | .outlines() |
| 397 | .iter() |
| 398 | .filter(|outline| !outline.is_empty()), |
| 399 | ); |
| 400 | let refused = |refusal, blocks| Reading { |
| 401 | verdict: Verdict::Refused(refusal), |
| 402 | blocks, |
| 403 | page: None, |
| 404 | }; |
| 405 | if page.rtl { |
| 406 | return Ok(refused(Refusal::RightToLeft, blocks)); |
| 407 | } |
| 408 | let left = blocks |
| 409 | .iter() |
| 410 | .map(|b| b.bounds[0]) |
| 411 | .fold(f32::INFINITY, f32::min); |
| 412 | let right = blocks |
| 413 | .iter() |
| 414 | .map(|b| b.bounds[2]) |
| 415 | .fold(f32::NEG_INFINITY, f32::max); |
| 416 | if blocks.is_empty() || right - left <= column + 1.0 { |
| 417 | return Ok(Reading { |
| 418 | verdict: Verdict::Fits, |
| 419 | blocks, |
| 420 | page: None, |
| 421 | }); |
| 422 | } |
| 423 | if let Some(refusal) = drawn_or_arranged(&blocks) { |
| 424 | return Ok(refused(refusal, blocks)); |
| 425 | } |
| 426 | let mut kept = Vec::new(); |
| 427 | if blocks.iter().any(|b| b.kind == Kind::Group) { |
| 428 | kept.push(Kept::Group); |
| 429 | } |
| 430 | let tops: BTreeMap<ExGuid, Vec<f32>> = editor |
| 431 | .outlines() |
| 432 | .iter() |
| 433 | .map(|outline| (outline.id, root_tops(outline))) |
| 434 | .collect(); |
| 435 | let blocks = order(blocks, &tops, &mut kept); |
| 436 | let start = editor.body_start().unwrap_or([left, blocks[0].bounds[1]]); |
| 437 | let mut reflowed = page.clone(); |
| 438 | let blocks = split(&mut reflowed, blocks); |
| 439 | reflow( |
| 440 | &mut reflowed, |
| 441 | &blocks, |
| 442 | [start[0].min(left), start[1]], |
| 443 | column, |
| 444 | engine, |
| 445 | &mut kept, |
| 446 | )?; |
| 447 | kept.sort(); |
| 448 | kept.dedup(); |
| 449 | Ok(Reading { |
| 450 | verdict: if kept.is_empty() { |
| 451 | Verdict::Reflows |
| 452 | } else { |
| 453 | Verdict::Partial(kept) |
| 454 | }, |
| 455 | blocks, |
| 456 | page: Some(reflowed), |
| 457 | }) |
| 458 | } |
| 459 | |
| 460 | /// Why `blocks` make a canvas rather than a document: mostly ink, or mostly overlapping. |
| 461 | fn drawn_or_arranged(blocks: &[Block]) -> Option<Refusal> { |
| 462 | let area = |kind: Option<Kind>| -> f64 { |
| 463 | blocks |
| 464 | .iter() |
| 465 | .filter(|b| kind.is_none_or(|kind| b.kind == kind)) |
| 466 | .map(|b| f64::from(b.bounds[2] - b.bounds[0]) * f64::from(b.bounds[3] - b.bounds[1])) |
| 467 | .sum() |
| 468 | }; |
| 469 | let total = area(None).max(1.0); |
| 470 | if area(Some(Kind::Drawing)) / total >= CANVAS_SHARE { |
| 471 | Some(Refusal::Drawn) |
| 472 | } else if area(Some(Kind::Group)) / total >= CANVAS_SHARE { |
| 473 | Some(Refusal::Arranged) |
| 474 | } else { |
| 475 | None |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | /// Where `outline`'s lines, table cells and pictures lie, in page points. |
| 480 | fn marks(outline: &TextOutline) -> Vec<[f32; 4]> { |
| 481 | let [x, y] = outline.origin(); |
| 482 | let shaped = outline.shaped(); |
| 483 | let lines = outline.layouts().flat_map(|(_, paragraph)| { |
| 484 | let [px, py] = paragraph.origin; |
| 485 | paragraph.text.lines().map(move |(line, bounds)| { |
| 486 | let metrics = line.metrics(); |
| 487 | let left = x + px + metrics.offset; |
| 488 | let top = y + py + bounds.top; |
| 489 | [ |
| 490 | left, |
| 491 | top, |
| 492 | left + metrics.advance - metrics.trailing_whitespace, |
| 493 | top + bounds.height, |
| 494 | ] |
| 495 | }) |
| 496 | }); |
| 497 | let cells = shaped |
| 498 | .tables |
| 499 | .iter() |
| 500 | .flat_map(|table| &table.cells) |
| 501 | .map(|cell| cell.rect); |
| 502 | let objects = shaped.objects.iter().map(|object| object.rect); |
| 503 | lines |
| 504 | .chain( |
| 505 | cells |
| 506 | .chain(objects) |
| 507 | .map(|rect| crate::translated(rect, [x, y])), |
| 508 | ) |
| 509 | .filter(|[x0, y0, x1, y1]| x1 > x0 && y1 > y0) |
| 510 | .collect() |
| 511 | } |
| 512 | |
| 513 | /// The page top of each of `outline`'s top-level paragraphs: its own text's, or the first |
| 514 | /// shown below it; the one before's where nothing below it shows. |
| 515 | fn root_tops(outline: &TextOutline) -> Vec<f32> { |
| 516 | let source = outline.snapshot(); |
| 517 | let mut root_of = BTreeMap::new(); |
| 518 | let mut roots = 0; |
| 519 | for paragraph in &source.paragraphs { |
| 520 | if paragraph.parent.is_none() { |
| 521 | roots += 1; |
| 522 | } |
| 523 | root_of.insert(paragraph.id, roots - 1); |
| 524 | } |
| 525 | let y = outline.origin()[1]; |
| 526 | let mut tops = vec![f32::INFINITY; roots]; |
| 527 | for (_, paragraph) in outline.layouts() { |
| 528 | if let Some(&root) = root_of.get(&paragraph.id) { |
| 529 | tops[root] = tops[root].min(y + paragraph.origin[1]); |
| 530 | } |
| 531 | } |
| 532 | let mut last = y; |
| 533 | for top in &mut tops { |
| 534 | if top.is_finite() { |
| 535 | last = *top; |
| 536 | } else { |
| 537 | *top = last; |
| 538 | } |
| 539 | } |
| 540 | tops |
| 541 | } |
| 542 | |
| 543 | /// A page object as laid out: its identity, kind, bounds, and where its lines and pictures |
| 544 | /// lie within them. |
| 545 | type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>); |
| 546 | |
| 547 | /// The page's content as laid out, `outlines` among it, overlapping pieces and nearby |
| 548 | /// strokes joined. |
| 549 | fn blocks<'a>( |
| 550 | editor: &CanvasEditor, |
| 551 | outlines: impl Iterator<Item = &'a TextOutline>, |
| 552 | ) -> Vec<Block> { |
| 553 | let mut items: Vec<Item> = outlines |
| 554 | .filter(|outline| !outline.title) |
| 555 | .map(|outline| { |
| 556 | let b = outline.bounds(); |
| 557 | let bounds = [b.x0, b.y0, b.x1, b.y1].map(|v| v as f32); |
| 558 | (outline.id, Kind::Text, bounds, marks(outline)) |
| 559 | }) |
| 560 | .collect(); |
| 561 | for object in &editor.objects { |
| 562 | let item = match object { |
| 563 | Content::Outline { |
| 564 | source, |
| 565 | layout, |
| 566 | below_title: None, |
| 567 | } => { |
| 568 | let [x, y] = crate::origin(&source.layout); |
| 569 | Some(( |
| 570 | source.id, |
| 571 | Kind::Text, |
| 572 | [x, y, x + layout.size[0], y + layout.size[1]], |
| 573 | )) |
| 574 | } |
| 575 | Content::Image(image) if !image.background => { |
| 576 | crate::outline::image_size(image).map(|[width, height]| { |
| 577 | let [x, y] = crate::origin(&image.layout); |
| 578 | (image.id, Kind::Picture, [x, y, x + width, y + height]) |
| 579 | }) |
| 580 | } |
| 581 | Content::File { source, .. } => object.file().map(|(_, b)| (source.id, Kind::File, b)), |
| 582 | Content::Ink(ink) => { |
| 583 | crate::editor::page::ink_bounds(ink).map(|b| (ink.id, Kind::Drawing, b)) |
| 584 | } |
| 585 | Content::ReadOnly(object) if !matches!(object.source, PageObject::Title(_)) => { |
| 586 | Some((object.source.id(), Kind::Picture, object.rect())) |
| 587 | } |
| 588 | _ => None, |
| 589 | }; |
| 590 | let item = item.filter(|(_, _, b)| b.iter().all(|v| v.is_finite())); |
| 591 | items.extend(item.map(|(id, kind, bounds)| (id, kind, bounds, vec![bounds]))); |
| 592 | } |
| 593 | let mut parent: Vec<usize> = (0..items.len()).collect(); |
| 594 | fn root(parent: &mut [usize], mut i: usize) -> usize { |
| 595 | while parent[i] != i { |
| 596 | parent[i] = parent[parent[i]]; |
| 597 | i = parent[i]; |
| 598 | } |
| 599 | i |
| 600 | } |
| 601 | let meets = |a: &[f32; 4], b: &[f32; 4], reach: f32| { |
| 602 | a[0] < b[2] + reach && b[0] < a[2] + reach && a[1] < b[3] + reach && b[1] < a[3] + reach |
| 603 | }; |
| 604 | for i in 0..items.len() { |
| 605 | for j in i + 1..items.len() { |
| 606 | let (a, b) = (&items[i], &items[j]); |
| 607 | // Touching edges are not overlap. |
| 608 | let reach = if a.1 == Kind::Drawing && b.1 == Kind::Drawing { |
| 609 | INK_REACH |
| 610 | } else { |
| 611 | -1.0 |
| 612 | }; |
| 613 | if meets(&a.2, &b.2, reach) |
| 614 | && a.3.iter().any(|m| b.3.iter().any(|n| meets(m, n, reach))) |
| 615 | { |
| 616 | let (ra, rb) = (root(&mut parent, i), root(&mut parent, j)); |
| 617 | parent[ra] = rb; |
| 618 | } |
| 619 | } |
| 620 | } |
| 621 | let mut joined: BTreeMap<usize, Block> = BTreeMap::new(); |
| 622 | for (i, (id, kind, bounds, _)) in items.into_iter().enumerate() { |
| 623 | let r = root(&mut parent, i); |
| 624 | joined |
| 625 | .entry(r) |
| 626 | .and_modify(|block| { |
| 627 | block.members.push(id); |
| 628 | if !(block.kind == Kind::Drawing && kind == Kind::Drawing) { |
| 629 | block.kind = Kind::Group; |
| 630 | } |
| 631 | block.bounds = union(block.bounds, bounds); |
| 632 | }) |
| 633 | .or_insert(Block { |
| 634 | kind, |
| 635 | members: vec![id], |
| 636 | bounds, |
| 637 | part: None, |
| 638 | aside: false, |
| 639 | }); |
| 640 | } |
| 641 | joined.into_values().collect() |
| 642 | } |
| 643 | |
| 644 | fn union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { |
| 645 | [ |
| 646 | a[0].min(b[0]), |
| 647 | a[1].min(b[1]), |
| 648 | a[2].max(b[2]), |
| 649 | a[3].max(b[3]), |
| 650 | ] |
| 651 | } |
| 652 | |
| 653 | /// `blocks` in reading order by recursive XY cut: rows first, then columns left to right, |
| 654 | /// notes beside a single outline as its asides. |
| 655 | fn order( |
| 656 | blocks: Vec<Block>, |
| 657 | tops: &BTreeMap<ExGuid, Vec<f32>>, |
| 658 | kept: &mut Vec<Kept>, |
| 659 | ) -> Vec<Block> { |
| 660 | if blocks.len() <= 1 { |
| 661 | return blocks; |
| 662 | } |
| 663 | let mut parts = cut(blocks, 1); |
| 664 | if let [row] = &mut parts[..] { |
| 665 | let row = std::mem::take(row); |
| 666 | if let Some(asides) = asides(&row, tops) { |
| 667 | kept.push(Kept::Asides); |
| 668 | return asides; |
| 669 | } |
| 670 | parts = cut(row, 0); |
| 671 | let texts = parts |
| 672 | .iter() |
| 673 | .filter(|part| part.iter().any(|b| b.kind == Kind::Text)); |
| 674 | if texts.count() > 1 { |
| 675 | kept.push(Kept::SideBySide); |
| 676 | } |
| 677 | } |
| 678 | if let [interlocked] = &mut parts[..] { |
| 679 | // No gap runs across: top to bottom, then left to right. |
| 680 | kept.push(Kept::SideBySide); |
| 681 | let mut blocks = std::mem::take(interlocked); |
| 682 | blocks.sort_by(|a, b| { |
| 683 | a.bounds[1] |
| 684 | .total_cmp(&b.bounds[1]) |
| 685 | .then(a.bounds[0].total_cmp(&b.bounds[0])) |
| 686 | }); |
| 687 | return blocks; |
| 688 | } |
| 689 | parts |
| 690 | .into_iter() |
| 691 | .flat_map(|part| order(part, tops, kept)) |
| 692 | .collect() |
| 693 | } |
| 694 | |
| 695 | /// `row` as one outline split after each paragraph a note beside it sits by, where every |
| 696 | /// other block is text no taller than the outline and clear of it across. |
| 697 | fn asides(row: &[Block], tops: &BTreeMap<ExGuid, Vec<f32>>) -> Option<Vec<Block>> { |
| 698 | let height = |b: &Block| b.bounds[3] - b.bounds[1]; |
| 699 | let main = row |
| 700 | .iter() |
| 701 | .filter(|b| b.kind == Kind::Text) |
| 702 | .max_by(|a, b| height(a).total_cmp(&height(b)))?; |
| 703 | let tops = tops.get(&main.members[0])?; |
| 704 | let mut notes: Vec<&Block> = row.iter().filter(|b| *b != main).collect(); |
| 705 | let beside = |b: &Block| { |
| 706 | b.kind == Kind::Text |
| 707 | && height(b) < height(main) |
| 708 | && (b.bounds[0] >= main.bounds[0] + 72.0 || b.bounds[2] <= main.bounds[0]) |
| 709 | }; |
| 710 | if notes.is_empty() || tops.len() < 2 || !notes.iter().all(|b| beside(b)) { |
| 711 | return None; |
| 712 | } |
| 713 | notes.sort_by(|a, b| a.bounds[1].total_cmp(&b.bounds[1])); |
| 714 | let mut out = Vec::new(); |
| 715 | let mut from = 0; |
| 716 | let slice = |range: Range<usize>| { |
| 717 | let mut bounds = main.bounds; |
| 718 | bounds[1] = tops[range.start]; |
| 719 | bounds[3] = tops.get(range.end).copied().unwrap_or(main.bounds[3]); |
| 720 | Block { |
| 721 | part: Some(range), |
| 722 | bounds, |
| 723 | ..main.clone() |
| 724 | } |
| 725 | }; |
| 726 | for note in notes { |
| 727 | let after = tops |
| 728 | .partition_point(|top| *top <= note.bounds[1] + ASIDE_SLACK) |
| 729 | .max(1); |
| 730 | if after > from { |
| 731 | out.push(slice(from..after)); |
| 732 | from = after; |
| 733 | } |
| 734 | out.push(Block { |
| 735 | aside: true, |
| 736 | ..note.clone() |
| 737 | }); |
| 738 | } |
| 739 | if from < tops.len() { |
| 740 | out.push(slice(from..tops.len())); |
| 741 | } |
| 742 | Some(out) |
| 743 | } |
| 744 | |
| 745 | /// `blocks` split at each gap along `axis` (0 for x, 1 for y) that no block spans. |
| 746 | fn cut(mut blocks: Vec<Block>, axis: usize) -> Vec<Vec<Block>> { |
| 747 | blocks.sort_by(|a, b| a.bounds[axis].total_cmp(&b.bounds[axis])); |
| 748 | let mut parts: Vec<Vec<Block>> = Vec::new(); |
| 749 | let mut end = f32::NEG_INFINITY; |
| 750 | for block in blocks { |
| 751 | if parts.is_empty() || block.bounds[axis] >= end { |
| 752 | parts.push(Vec::new()); |
| 753 | } |
| 754 | end = end.max(block.bounds[axis + 2]); |
| 755 | parts.last_mut().unwrap().push(block); |
| 756 | } |
| 757 | parts |
| 758 | } |
| 759 | |
| 760 | /// Splits each outline asides cut into one outline per part, shown only: the first part |
| 761 | /// keeps the outline's identity, the others take ones derived from it. |
| 762 | fn split(page: &mut Page, mut blocks: Vec<Block>) -> Vec<Block> { |
| 763 | let mut whole: BTreeMap<ExGuid, Outline> = BTreeMap::new(); |
| 764 | for block in &mut blocks { |
| 765 | let Some(range) = block.part.clone() else { |
| 766 | continue; |
| 767 | }; |
| 768 | let id = block.members[0]; |
| 769 | let source = match whole.get(&id) { |
| 770 | Some(source) => source.clone(), |
| 771 | None => { |
| 772 | let Some(PageObject::Outline(source)) = object_mut(page, id).cloned() else { |
| 773 | continue; |
| 774 | }; |
| 775 | whole.insert(id, source.clone()); |
| 776 | source |
| 777 | } |
| 778 | }; |
| 779 | let mut root = 0; |
| 780 | let paragraphs = source |
| 781 | .paragraphs |
| 782 | .iter() |
| 783 | .filter(|paragraph| { |
| 784 | if paragraph.parent.is_none() { |
| 785 | root += 1; |
| 786 | } |
| 787 | range.contains(&(root - 1)) |
| 788 | }) |
| 789 | .cloned() |
| 790 | .collect(); |
| 791 | let part = Outline { |
| 792 | id: if range.start == 0 { |
| 793 | id |
| 794 | } else { |
| 795 | ExGuid { |
| 796 | guid: id.guid, |
| 797 | n: id.n ^ (0x8000_0000 | range.start as u32), |
| 798 | } |
| 799 | }, |
| 800 | paragraphs, |
| 801 | ..source |
| 802 | }; |
| 803 | block.members[0] = part.id; |
| 804 | match object_mut(page, part.id) { |
| 805 | Some(object) => *object = PageObject::Outline(part), |
| 806 | None => page.objects.push(PageObject::Outline(part)), |
| 807 | } |
| 808 | } |
| 809 | blocks |
| 810 | } |
| 811 | |
| 812 | /// Stacks `blocks` in order from `start` down a column `column` wide. |
| 813 | fn reflow( |
| 814 | page: &mut Page, |
| 815 | blocks: &[Block], |
| 816 | start: [f32; 2], |
| 817 | column: f32, |
| 818 | engine: &mut TextEngine, |
| 819 | kept: &mut Vec<Kept>, |
| 820 | ) -> Result<(), EditorError> { |
| 821 | let [left, mut y] = start; |
| 822 | // Widths first: text rewraps, pictures shrink, the rest moves across whole. |
| 823 | let mut heights = Vec::with_capacity(blocks.len()); |
| 824 | for block in blocks { |
| 825 | let [x0, y0, x1, y1] = block.bounds; |
| 826 | let mut height = y1 - y0; |
| 827 | let indent = if block.aside { ASIDE_INDENT } else { 0.0 }; |
| 828 | match (block.kind, object_mut(page, block.members[0])) { |
| 829 | (Kind::Text, Some(PageObject::Outline(outline))) => { |
| 830 | outline.layout.x = Some(left + indent); |
| 831 | outline.layout.max_width = Some((x1 - x0).min(column - indent)); |
| 832 | outline.layout.reserved_width = None; |
| 833 | outline.min_width = None; |
| 834 | collapse_blanks(outline); |
| 835 | for paragraph in &mut outline.paragraphs { |
| 836 | if let ParagraphContent::Image(image) = &mut paragraph.content { |
| 837 | shrink(image, column - 36.0); |
| 838 | } |
| 839 | } |
| 840 | } |
| 841 | (Kind::Picture, Some(PageObject::Image(image))) => { |
| 842 | image.layout.x = Some(left); |
| 843 | height = shrink(image, column).unwrap_or(height); |
| 844 | } |
| 845 | _ => { |
| 846 | for &id in &block.members { |
| 847 | if let Some(object) = object_mut(page, id) { |
| 848 | let layout = object.layout_mut(); |
| 849 | layout.x = Some(layout.x.unwrap_or(0.0) + left - x0); |
| 850 | } |
| 851 | } |
| 852 | if x1 - x0 > column + 1.0 { |
| 853 | kept.push(Kept::Wide); |
| 854 | } |
| 855 | } |
| 856 | } |
| 857 | heights.push(height); |
| 858 | } |
| 859 | // Text heights at the new widths. |
| 860 | let laid = CanvasEditor::from_page(page.clone(), engine)?; |
| 861 | for (block, height) in blocks.iter().zip(&mut heights) { |
| 862 | if block.kind != Kind::Text { |
| 863 | continue; |
| 864 | } |
| 865 | if let Some(outline) = laid.outlines().iter().find(|o| o.id == block.members[0]) { |
| 866 | let b = outline.bounds(); |
| 867 | *height = (b.y1 - b.y0) as f32; |
| 868 | if (b.x1 - b.x0) as f32 > column + 1.0 { |
| 869 | kept.push(Kept::Wide); |
| 870 | } |
| 871 | } |
| 872 | } |
| 873 | for (index, (block, height)) in blocks.iter().zip(&heights).enumerate() { |
| 874 | // Asides and the parts of the outline they split sit close. |
| 875 | let close = |block: &Block| block.aside || block.part.as_ref().is_some_and(|p| p.start > 0); |
| 876 | if index > 0 { |
| 877 | let previous = &blocks[index - 1]; |
| 878 | y += if close(block) || previous.aside { |
| 879 | ASIDE_GAP |
| 880 | } else { |
| 881 | GAP |
| 882 | }; |
| 883 | } |
| 884 | let dy = y - block.bounds[1]; |
| 885 | for (index, &id) in block.members.iter().enumerate() { |
| 886 | if let Some(object) = object_mut(page, id) { |
| 887 | let layout = object.layout_mut(); |
| 888 | layout.y = Some( |
| 889 | if index == 0 && matches!(block.kind, Kind::Text | Kind::Picture) { |
| 890 | y |
| 891 | } else { |
| 892 | layout.y.unwrap_or(0.0) + dy |
| 893 | }, |
| 894 | ); |
| 895 | } |
| 896 | } |
| 897 | y += height; |
| 898 | } |
| 899 | Ok(()) |
| 900 | } |
| 901 | |
| 902 | /// Drops each blank top-level paragraph that follows another, as spacing kept a side |
| 903 | /// column level with the text beside it. |
| 904 | fn collapse_blanks(outline: &mut Outline) { |
| 905 | let blank = |p: &onestore::page::PageParagraph| { |
| 906 | p.lists.is_empty() |
| 907 | && p.tags.is_empty() |
| 908 | && p.text() |
| 909 | .is_some_and(|t| t.tags.is_empty() && t.text.text().trim().is_empty()) |
| 910 | }; |
| 911 | let parents: std::collections::BTreeSet<ExGuid> = |
| 912 | outline.paragraphs.iter().filter_map(|p| p.parent).collect(); |
| 913 | let mut previous_blank = false; |
| 914 | let mut root_kept = true; |
| 915 | outline.paragraphs.retain(|p| { |
| 916 | if p.parent.is_some() { |
| 917 | return root_kept; |
| 918 | } |
| 919 | let is_blank = blank(p) && !parents.contains(&p.id); |
| 920 | root_kept = !(is_blank && previous_blank); |
| 921 | previous_blank = is_blank; |
| 922 | root_kept |
| 923 | }); |
| 924 | } |
| 925 | |
| 926 | fn object_mut(page: &mut Page, id: ExGuid) -> Option<&mut PageObject> { |
| 927 | page.objects.iter_mut().find(|object| object.id() == id) |
| 928 | } |
| 929 | |
| 930 | /// Scales `image` down to `width` points across, keeping its shape; its new height. |
| 931 | fn shrink(image: &mut onestore::page::Image, width: f32) -> Option<f32> { |
| 932 | let [w, h] = crate::outline::image_size(image)?; |
| 933 | let scale = (width / w).min(1.0); |
| 934 | image.layout.max_width = Some(w * scale); |
| 935 | image.layout.max_height = Some(h * scale); |
| 936 | Some(h * scale) |
| 937 | } |