| 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, TextOutline, page::Content}, |
| 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 | /// Points between blocks in the column: OneNote's grid row. |
| 22 | const GAP: f32 = 18.0; |
| 23 | /// Strokes this near one another are one drawing, as handwriting is many strokes. |
| 24 | const INK_REACH: f32 = 9.0; |
| 25 | /// The share of the content's area drawn, or kept as laid out, past which the page is a |
| 26 | /// canvas rather than a document. |
| 27 | const CANVAS_SHARE: f64 = 0.5; |
| 28 | /// How far below a paragraph's top a note beside it may start and still follow it. |
| 29 | const ASIDE_SLACK: f32 = 6.0; |
| 30 | /// How far an aside sits in from the column's edge, and the room above and below it. |
| 31 | const ASIDE_INDENT: f32 = 18.0; |
| 32 | const ASIDE_GAP: f32 = 6.0; |
| 33 | |
| 34 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 35 | pub enum Kind { |
| 36 | Text, |
| 37 | Picture, |
| 38 | File, |
| 39 | Drawing, |
| 40 | /// Overlapping blocks or ink over text, moved as one piece. |
| 41 | Group, |
| 42 | } |
| 43 | |
| 44 | #[derive(Clone, Debug, PartialEq)] |
| 45 | pub struct Block { |
| 46 | pub kind: Kind, |
| 47 | /// The page objects it moves. |
| 48 | pub members: Vec<ExGuid>, |
| 49 | /// `[x0, y0, x1, y1]` in page points, as laid out on the page. |
| 50 | pub bounds: [f32; 4], |
| 51 | /// The outline's top-level paragraphs it shows, where asides split the outline. |
| 52 | pub part: Option<Range<usize>>, |
| 53 | /// A note beside an outline, read after the paragraph it sits by. |
| 54 | pub aside: bool, |
| 55 | } |
| 56 | |
| 57 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 58 | pub enum Verdict { |
| 59 | /// No wider than the column already: shown as laid out. |
| 60 | Fits, |
| 61 | /// Every block flows into the column. |
| 62 | Reflows, |
| 63 | /// Flows, with what each `Kept` names read in an order or shape the page decides. |
| 64 | Partial(Vec<Kept>), |
| 65 | /// Not offered. |
| 66 | Refused(Refusal), |
| 67 | } |
| 68 | |
| 69 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 70 | pub enum Kept { |
| 71 | /// Notes beside an outline, each placed after the paragraph it sits beside. |
| 72 | Asides, |
| 73 | /// Blocks side by side, or interlocked, read left column first. |
| 74 | SideBySide, |
| 75 | /// Overlapping blocks or ink over text, moved whole at their size. |
| 76 | Group, |
| 77 | /// Something still wider than the column: a table with set column widths, a drawing, a |
| 78 | /// group. |
| 79 | Wide, |
| 80 | } |
| 81 | |
| 82 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 83 | pub enum Refusal { |
| 84 | /// Its positions read from the right; not yet reflowed. |
| 85 | RightToLeft, |
| 86 | /// Mostly ink. |
| 87 | Drawn, |
| 88 | /// Mostly overlapping blocks or ink over text. |
| 89 | Arranged, |
| 90 | } |
| 91 | |
| 92 | pub struct Reading { |
| 93 | pub verdict: Verdict, |
| 94 | /// In reading order where the page reflows, else as found. |
| 95 | pub blocks: Vec<Block>, |
| 96 | /// The page reflowed, where it reflows. |
| 97 | pub page: Option<Page>, |
| 98 | } |
| 99 | |
| 100 | impl Verdict { |
| 101 | /// Whether the page has a reading view. |
| 102 | pub fn offered(&self) -> bool { |
| 103 | matches!(self, Self::Reflows | Self::Partial(_)) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// `page` read into a column `column` points wide. |
| 108 | pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading, EditorError> { |
| 109 | let editor = CanvasEditor::from_page(page.clone(), engine)?; |
| 110 | let blocks = blocks(&editor); |
| 111 | let refused = |refusal, blocks| Reading { |
| 112 | verdict: Verdict::Refused(refusal), |
| 113 | blocks, |
| 114 | page: None, |
| 115 | }; |
| 116 | if page.rtl { |
| 117 | return Ok(refused(Refusal::RightToLeft, blocks)); |
| 118 | } |
| 119 | let left = blocks |
| 120 | .iter() |
| 121 | .map(|b| b.bounds[0]) |
| 122 | .fold(f32::INFINITY, f32::min); |
| 123 | let right = blocks |
| 124 | .iter() |
| 125 | .map(|b| b.bounds[2]) |
| 126 | .fold(f32::NEG_INFINITY, f32::max); |
| 127 | if blocks.is_empty() || right - left <= column + 1.0 { |
| 128 | return Ok(Reading { |
| 129 | verdict: Verdict::Fits, |
| 130 | blocks, |
| 131 | page: None, |
| 132 | }); |
| 133 | } |
| 134 | let area = |kind: Option<Kind>| -> f64 { |
| 135 | blocks |
| 136 | .iter() |
| 137 | .filter(|b| kind.is_none_or(|kind| b.kind == kind)) |
| 138 | .map(|b| f64::from(b.bounds[2] - b.bounds[0]) * f64::from(b.bounds[3] - b.bounds[1])) |
| 139 | .sum() |
| 140 | }; |
| 141 | let total = area(None).max(1.0); |
| 142 | if area(Some(Kind::Drawing)) / total >= CANVAS_SHARE { |
| 143 | return Ok(refused(Refusal::Drawn, blocks)); |
| 144 | } |
| 145 | if area(Some(Kind::Group)) / total >= CANVAS_SHARE { |
| 146 | return Ok(refused(Refusal::Arranged, blocks)); |
| 147 | } |
| 148 | let mut kept = Vec::new(); |
| 149 | if blocks.iter().any(|b| b.kind == Kind::Group) { |
| 150 | kept.push(Kept::Group); |
| 151 | } |
| 152 | let tops: BTreeMap<ExGuid, Vec<f32>> = editor |
| 153 | .outlines() |
| 154 | .iter() |
| 155 | .map(|outline| (outline.id, root_tops(outline))) |
| 156 | .collect(); |
| 157 | let blocks = order(blocks, &tops, &mut kept); |
| 158 | let start = editor.body_start().unwrap_or([left, blocks[0].bounds[1]]); |
| 159 | let mut reflowed = page.clone(); |
| 160 | let blocks = split(&mut reflowed, blocks); |
| 161 | reflow( |
| 162 | &mut reflowed, |
| 163 | &blocks, |
| 164 | [start[0].min(left), start[1]], |
| 165 | column, |
| 166 | engine, |
| 167 | &mut kept, |
| 168 | )?; |
| 169 | kept.sort(); |
| 170 | kept.dedup(); |
| 171 | Ok(Reading { |
| 172 | verdict: if kept.is_empty() { |
| 173 | Verdict::Reflows |
| 174 | } else { |
| 175 | Verdict::Partial(kept) |
| 176 | }, |
| 177 | blocks, |
| 178 | page: Some(reflowed), |
| 179 | }) |
| 180 | } |
| 181 | |
| 182 | /// Where `outline`'s lines, table cells and pictures lie, in page points. |
| 183 | fn marks(outline: &TextOutline) -> Vec<[f32; 4]> { |
| 184 | let [x, y] = outline.origin(); |
| 185 | let shaped = outline.shaped(); |
| 186 | let lines = outline.layouts().flat_map(|(_, paragraph)| { |
| 187 | let [px, py] = paragraph.origin; |
| 188 | paragraph.text.lines().map(move |(line, bounds)| { |
| 189 | let metrics = line.metrics(); |
| 190 | let left = x + px + metrics.offset; |
| 191 | let top = y + py + bounds.top; |
| 192 | [ |
| 193 | left, |
| 194 | top, |
| 195 | left + metrics.advance - metrics.trailing_whitespace, |
| 196 | top + bounds.height, |
| 197 | ] |
| 198 | }) |
| 199 | }); |
| 200 | let cells = shaped |
| 201 | .tables |
| 202 | .iter() |
| 203 | .flat_map(|table| &table.cells) |
| 204 | .map(|cell| cell.rect); |
| 205 | let objects = shaped.objects.iter().map(|object| object.rect); |
| 206 | lines |
| 207 | .chain( |
| 208 | cells |
| 209 | .chain(objects) |
| 210 | .map(|rect| crate::translated(rect, [x, y])), |
| 211 | ) |
| 212 | .filter(|[x0, y0, x1, y1]| x1 > x0 && y1 > y0) |
| 213 | .collect() |
| 214 | } |
| 215 | |
| 216 | /// The page top of each of `outline`'s top-level paragraphs: its own text's, or the first |
| 217 | /// shown below it; the one before's where nothing below it shows. |
| 218 | fn root_tops(outline: &TextOutline) -> Vec<f32> { |
| 219 | let source = outline.snapshot(); |
| 220 | let mut root_of = BTreeMap::new(); |
| 221 | let mut roots = 0; |
| 222 | for paragraph in &source.paragraphs { |
| 223 | if paragraph.parent.is_none() { |
| 224 | roots += 1; |
| 225 | } |
| 226 | root_of.insert(paragraph.id, roots - 1); |
| 227 | } |
| 228 | let y = outline.origin()[1]; |
| 229 | let mut tops = vec![f32::INFINITY; roots]; |
| 230 | for (_, paragraph) in outline.layouts() { |
| 231 | if let Some(&root) = root_of.get(&paragraph.id) { |
| 232 | tops[root] = tops[root].min(y + paragraph.origin[1]); |
| 233 | } |
| 234 | } |
| 235 | let mut last = y; |
| 236 | for top in &mut tops { |
| 237 | if top.is_finite() { |
| 238 | last = *top; |
| 239 | } else { |
| 240 | *top = last; |
| 241 | } |
| 242 | } |
| 243 | tops |
| 244 | } |
| 245 | |
| 246 | /// A page object as laid out: its identity, kind, bounds, and where its lines and pictures |
| 247 | /// lie within them. |
| 248 | type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>); |
| 249 | |
| 250 | /// The page's content as laid out, overlapping pieces and nearby strokes joined. |
| 251 | fn blocks(editor: &CanvasEditor) -> Vec<Block> { |
| 252 | let mut items: Vec<Item> = editor |
| 253 | .outlines() |
| 254 | .iter() |
| 255 | .filter(|outline| !outline.title && !outline.is_empty()) |
| 256 | .map(|outline| { |
| 257 | let b = outline.bounds(); |
| 258 | let bounds = [b.x0, b.y0, b.x1, b.y1].map(|v| v as f32); |
| 259 | (outline.id, Kind::Text, bounds, marks(outline)) |
| 260 | }) |
| 261 | .collect(); |
| 262 | for object in &editor.objects { |
| 263 | let item = match object { |
| 264 | Content::Outline { |
| 265 | source, |
| 266 | layout, |
| 267 | below_title: None, |
| 268 | } => { |
| 269 | let [x, y] = crate::origin(&source.layout); |
| 270 | Some(( |
| 271 | source.id, |
| 272 | Kind::Text, |
| 273 | [x, y, x + layout.size[0], y + layout.size[1]], |
| 274 | )) |
| 275 | } |
| 276 | Content::Image(image) if !image.background => { |
| 277 | crate::outline::image_size(image).map(|[width, height]| { |
| 278 | let [x, y] = crate::origin(&image.layout); |
| 279 | (image.id, Kind::Picture, [x, y, x + width, y + height]) |
| 280 | }) |
| 281 | } |
| 282 | Content::File { source, .. } => object.file().map(|(_, b)| (source.id, Kind::File, b)), |
| 283 | Content::Ink(ink) => { |
| 284 | crate::editor::page::ink_bounds(ink).map(|b| (ink.id, Kind::Drawing, b)) |
| 285 | } |
| 286 | Content::ReadOnly(object) if !matches!(object.source, PageObject::Title(_)) => { |
| 287 | Some((object.source.id(), Kind::Picture, object.rect())) |
| 288 | } |
| 289 | _ => None, |
| 290 | }; |
| 291 | let item = item.filter(|(_, _, b)| b.iter().all(|v| v.is_finite())); |
| 292 | items.extend(item.map(|(id, kind, bounds)| (id, kind, bounds, vec![bounds]))); |
| 293 | } |
| 294 | let mut parent: Vec<usize> = (0..items.len()).collect(); |
| 295 | fn root(parent: &mut [usize], mut i: usize) -> usize { |
| 296 | while parent[i] != i { |
| 297 | parent[i] = parent[parent[i]]; |
| 298 | i = parent[i]; |
| 299 | } |
| 300 | i |
| 301 | } |
| 302 | let meets = |a: &[f32; 4], b: &[f32; 4], reach: f32| { |
| 303 | a[0] < b[2] + reach && b[0] < a[2] + reach && a[1] < b[3] + reach && b[1] < a[3] + reach |
| 304 | }; |
| 305 | for i in 0..items.len() { |
| 306 | for j in i + 1..items.len() { |
| 307 | let (a, b) = (&items[i], &items[j]); |
| 308 | // Touching edges are not overlap. |
| 309 | let reach = if a.1 == Kind::Drawing && b.1 == Kind::Drawing { |
| 310 | INK_REACH |
| 311 | } else { |
| 312 | -1.0 |
| 313 | }; |
| 314 | if meets(&a.2, &b.2, reach) |
| 315 | && a.3.iter().any(|m| b.3.iter().any(|n| meets(m, n, reach))) |
| 316 | { |
| 317 | let (ra, rb) = (root(&mut parent, i), root(&mut parent, j)); |
| 318 | parent[ra] = rb; |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | let mut joined: BTreeMap<usize, Block> = BTreeMap::new(); |
| 323 | for (i, (id, kind, bounds, _)) in items.into_iter().enumerate() { |
| 324 | let r = root(&mut parent, i); |
| 325 | joined |
| 326 | .entry(r) |
| 327 | .and_modify(|block| { |
| 328 | block.members.push(id); |
| 329 | if !(block.kind == Kind::Drawing && kind == Kind::Drawing) { |
| 330 | block.kind = Kind::Group; |
| 331 | } |
| 332 | block.bounds = union(block.bounds, bounds); |
| 333 | }) |
| 334 | .or_insert(Block { |
| 335 | kind, |
| 336 | members: vec![id], |
| 337 | bounds, |
| 338 | part: None, |
| 339 | aside: false, |
| 340 | }); |
| 341 | } |
| 342 | joined.into_values().collect() |
| 343 | } |
| 344 | |
| 345 | fn union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { |
| 346 | [ |
| 347 | a[0].min(b[0]), |
| 348 | a[1].min(b[1]), |
| 349 | a[2].max(b[2]), |
| 350 | a[3].max(b[3]), |
| 351 | ] |
| 352 | } |
| 353 | |
| 354 | /// `blocks` in reading order by recursive XY cut: rows first, then columns left to right, |
| 355 | /// notes beside a single outline as its asides. |
| 356 | fn order( |
| 357 | blocks: Vec<Block>, |
| 358 | tops: &BTreeMap<ExGuid, Vec<f32>>, |
| 359 | kept: &mut Vec<Kept>, |
| 360 | ) -> Vec<Block> { |
| 361 | if blocks.len() <= 1 { |
| 362 | return blocks; |
| 363 | } |
| 364 | let mut parts = cut(blocks, 1); |
| 365 | if let [row] = &mut parts[..] { |
| 366 | let row = std::mem::take(row); |
| 367 | if let Some(asides) = asides(&row, tops) { |
| 368 | kept.push(Kept::Asides); |
| 369 | return asides; |
| 370 | } |
| 371 | parts = cut(row, 0); |
| 372 | let texts = parts |
| 373 | .iter() |
| 374 | .filter(|part| part.iter().any(|b| b.kind == Kind::Text)); |
| 375 | if texts.count() > 1 { |
| 376 | kept.push(Kept::SideBySide); |
| 377 | } |
| 378 | } |
| 379 | if let [interlocked] = &mut parts[..] { |
| 380 | // No gap runs across: top to bottom, then left to right. |
| 381 | kept.push(Kept::SideBySide); |
| 382 | let mut blocks = std::mem::take(interlocked); |
| 383 | blocks.sort_by(|a, b| { |
| 384 | a.bounds[1] |
| 385 | .total_cmp(&b.bounds[1]) |
| 386 | .then(a.bounds[0].total_cmp(&b.bounds[0])) |
| 387 | }); |
| 388 | return blocks; |
| 389 | } |
| 390 | parts |
| 391 | .into_iter() |
| 392 | .flat_map(|part| order(part, tops, kept)) |
| 393 | .collect() |
| 394 | } |
| 395 | |
| 396 | /// `row` as one outline split after each paragraph a note beside it sits by, where every |
| 397 | /// other block is text no taller than the outline and clear of it across. |
| 398 | fn asides(row: &[Block], tops: &BTreeMap<ExGuid, Vec<f32>>) -> Option<Vec<Block>> { |
| 399 | let height = |b: &Block| b.bounds[3] - b.bounds[1]; |
| 400 | let main = row |
| 401 | .iter() |
| 402 | .filter(|b| b.kind == Kind::Text) |
| 403 | .max_by(|a, b| height(a).total_cmp(&height(b)))?; |
| 404 | let tops = tops.get(&main.members[0])?; |
| 405 | let mut notes: Vec<&Block> = row.iter().filter(|b| *b != main).collect(); |
| 406 | let beside = |b: &Block| { |
| 407 | b.kind == Kind::Text |
| 408 | && height(b) < height(main) |
| 409 | && (b.bounds[0] >= main.bounds[0] + 72.0 || b.bounds[2] <= main.bounds[0]) |
| 410 | }; |
| 411 | if notes.is_empty() || tops.len() < 2 || !notes.iter().all(|b| beside(b)) { |
| 412 | return None; |
| 413 | } |
| 414 | notes.sort_by(|a, b| a.bounds[1].total_cmp(&b.bounds[1])); |
| 415 | let mut out = Vec::new(); |
| 416 | let mut from = 0; |
| 417 | let slice = |range: Range<usize>| { |
| 418 | let mut bounds = main.bounds; |
| 419 | bounds[1] = tops[range.start]; |
| 420 | bounds[3] = tops.get(range.end).copied().unwrap_or(main.bounds[3]); |
| 421 | Block { |
| 422 | part: Some(range), |
| 423 | bounds, |
| 424 | ..main.clone() |
| 425 | } |
| 426 | }; |
| 427 | for note in notes { |
| 428 | let after = tops |
| 429 | .partition_point(|top| *top <= note.bounds[1] + ASIDE_SLACK) |
| 430 | .max(1); |
| 431 | if after > from { |
| 432 | out.push(slice(from..after)); |
| 433 | from = after; |
| 434 | } |
| 435 | out.push(Block { |
| 436 | aside: true, |
| 437 | ..note.clone() |
| 438 | }); |
| 439 | } |
| 440 | if from < tops.len() { |
| 441 | out.push(slice(from..tops.len())); |
| 442 | } |
| 443 | Some(out) |
| 444 | } |
| 445 | |
| 446 | /// `blocks` split at each gap along `axis` (0 for x, 1 for y) that no block spans. |
| 447 | fn cut(mut blocks: Vec<Block>, axis: usize) -> Vec<Vec<Block>> { |
| 448 | blocks.sort_by(|a, b| a.bounds[axis].total_cmp(&b.bounds[axis])); |
| 449 | let mut parts: Vec<Vec<Block>> = Vec::new(); |
| 450 | let mut end = f32::NEG_INFINITY; |
| 451 | for block in blocks { |
| 452 | if parts.is_empty() || block.bounds[axis] >= end { |
| 453 | parts.push(Vec::new()); |
| 454 | } |
| 455 | end = end.max(block.bounds[axis + 2]); |
| 456 | parts.last_mut().unwrap().push(block); |
| 457 | } |
| 458 | parts |
| 459 | } |
| 460 | |
| 461 | /// Splits each outline asides cut into one outline per part, shown only: the first part |
| 462 | /// keeps the outline's identity, the others take ones derived from it. |
| 463 | fn split(page: &mut Page, mut blocks: Vec<Block>) -> Vec<Block> { |
| 464 | let mut whole: BTreeMap<ExGuid, Outline> = BTreeMap::new(); |
| 465 | for block in &mut blocks { |
| 466 | let Some(range) = block.part.clone() else { |
| 467 | continue; |
| 468 | }; |
| 469 | let id = block.members[0]; |
| 470 | let source = match whole.get(&id) { |
| 471 | Some(source) => source.clone(), |
| 472 | None => { |
| 473 | let Some(PageObject::Outline(source)) = object_mut(page, id).cloned() else { |
| 474 | continue; |
| 475 | }; |
| 476 | whole.insert(id, source.clone()); |
| 477 | source |
| 478 | } |
| 479 | }; |
| 480 | let mut root = 0; |
| 481 | let paragraphs = source |
| 482 | .paragraphs |
| 483 | .iter() |
| 484 | .filter(|paragraph| { |
| 485 | if paragraph.parent.is_none() { |
| 486 | root += 1; |
| 487 | } |
| 488 | range.contains(&(root - 1)) |
| 489 | }) |
| 490 | .cloned() |
| 491 | .collect(); |
| 492 | let part = Outline { |
| 493 | id: if range.start == 0 { |
| 494 | id |
| 495 | } else { |
| 496 | ExGuid { |
| 497 | guid: id.guid, |
| 498 | n: id.n ^ (0x8000_0000 | range.start as u32), |
| 499 | } |
| 500 | }, |
| 501 | paragraphs, |
| 502 | ..source |
| 503 | }; |
| 504 | block.members[0] = part.id; |
| 505 | match object_mut(page, part.id) { |
| 506 | Some(object) => *object = PageObject::Outline(part), |
| 507 | None => page.objects.push(PageObject::Outline(part)), |
| 508 | } |
| 509 | } |
| 510 | blocks |
| 511 | } |
| 512 | |
| 513 | /// Stacks `blocks` in order from `start` down a column `column` wide. |
| 514 | fn reflow( |
| 515 | page: &mut Page, |
| 516 | blocks: &[Block], |
| 517 | start: [f32; 2], |
| 518 | column: f32, |
| 519 | engine: &mut TextEngine, |
| 520 | kept: &mut Vec<Kept>, |
| 521 | ) -> Result<(), EditorError> { |
| 522 | let [left, mut y] = start; |
| 523 | // Widths first: text rewraps, pictures shrink, the rest moves across whole. |
| 524 | let mut heights = Vec::with_capacity(blocks.len()); |
| 525 | for block in blocks { |
| 526 | let [x0, y0, x1, y1] = block.bounds; |
| 527 | let mut height = y1 - y0; |
| 528 | let indent = if block.aside { ASIDE_INDENT } else { 0.0 }; |
| 529 | match (block.kind, object_mut(page, block.members[0])) { |
| 530 | (Kind::Text, Some(PageObject::Outline(outline))) => { |
| 531 | outline.layout.x = Some(left + indent); |
| 532 | outline.layout.max_width = Some((x1 - x0).min(column - indent)); |
| 533 | outline.layout.reserved_width = None; |
| 534 | outline.min_width = None; |
| 535 | collapse_blanks(outline); |
| 536 | for paragraph in &mut outline.paragraphs { |
| 537 | if let ParagraphContent::Image(image) = &mut paragraph.content { |
| 538 | shrink(image, column - 36.0); |
| 539 | } |
| 540 | } |
| 541 | } |
| 542 | (Kind::Picture, Some(PageObject::Image(image))) => { |
| 543 | image.layout.x = Some(left); |
| 544 | height = shrink(image, column).unwrap_or(height); |
| 545 | } |
| 546 | _ => { |
| 547 | for &id in &block.members { |
| 548 | if let Some(object) = object_mut(page, id) { |
| 549 | let layout = object.layout_mut(); |
| 550 | layout.x = Some(layout.x.unwrap_or(0.0) + left - x0); |
| 551 | } |
| 552 | } |
| 553 | if x1 - x0 > column + 1.0 { |
| 554 | kept.push(Kept::Wide); |
| 555 | } |
| 556 | } |
| 557 | } |
| 558 | heights.push(height); |
| 559 | } |
| 560 | // Text heights at the new widths. |
| 561 | let laid = CanvasEditor::from_page(page.clone(), engine)?; |
| 562 | for (block, height) in blocks.iter().zip(&mut heights) { |
| 563 | if block.kind != Kind::Text { |
| 564 | continue; |
| 565 | } |
| 566 | if let Some(outline) = laid.outlines().iter().find(|o| o.id == block.members[0]) { |
| 567 | let b = outline.bounds(); |
| 568 | *height = (b.y1 - b.y0) as f32; |
| 569 | if (b.x1 - b.x0) as f32 > column + 1.0 { |
| 570 | kept.push(Kept::Wide); |
| 571 | } |
| 572 | } |
| 573 | } |
| 574 | for (index, (block, height)) in blocks.iter().zip(&heights).enumerate() { |
| 575 | // Asides and the parts of the outline they split sit close. |
| 576 | let close = |block: &Block| block.aside || block.part.as_ref().is_some_and(|p| p.start > 0); |
| 577 | if index > 0 { |
| 578 | let previous = &blocks[index - 1]; |
| 579 | y += if close(block) || previous.aside { |
| 580 | ASIDE_GAP |
| 581 | } else { |
| 582 | GAP |
| 583 | }; |
| 584 | } |
| 585 | let dy = y - block.bounds[1]; |
| 586 | for (index, &id) in block.members.iter().enumerate() { |
| 587 | if let Some(object) = object_mut(page, id) { |
| 588 | let layout = object.layout_mut(); |
| 589 | layout.y = Some( |
| 590 | if index == 0 && matches!(block.kind, Kind::Text | Kind::Picture) { |
| 591 | y |
| 592 | } else { |
| 593 | layout.y.unwrap_or(0.0) + dy |
| 594 | }, |
| 595 | ); |
| 596 | } |
| 597 | } |
| 598 | y += height; |
| 599 | } |
| 600 | Ok(()) |
| 601 | } |
| 602 | |
| 603 | /// Drops each blank top-level paragraph that follows another, as spacing kept a side |
| 604 | /// column level with the text beside it. |
| 605 | fn collapse_blanks(outline: &mut Outline) { |
| 606 | let blank = |p: &onestore::page::PageParagraph| { |
| 607 | p.lists.is_empty() |
| 608 | && p.tags.is_empty() |
| 609 | && p.text() |
| 610 | .is_some_and(|t| t.tags.is_empty() && t.text.text().trim().is_empty()) |
| 611 | }; |
| 612 | let parents: std::collections::BTreeSet<ExGuid> = |
| 613 | outline.paragraphs.iter().filter_map(|p| p.parent).collect(); |
| 614 | let mut previous_blank = false; |
| 615 | let mut root_kept = true; |
| 616 | outline.paragraphs.retain(|p| { |
| 617 | if p.parent.is_some() { |
| 618 | return root_kept; |
| 619 | } |
| 620 | let is_blank = blank(p) && !parents.contains(&p.id); |
| 621 | root_kept = !(is_blank && previous_blank); |
| 622 | previous_blank = is_blank; |
| 623 | root_kept |
| 624 | }); |
| 625 | } |
| 626 | |
| 627 | fn object_mut(page: &mut Page, id: ExGuid) -> Option<&mut PageObject> { |
| 628 | page.objects.iter_mut().find(|object| object.id() == id) |
| 629 | } |
| 630 | |
| 631 | /// Scales `image` down to `width` points across, keeping its shape; its new height. |
| 632 | fn shrink(image: &mut onestore::page::Image, width: f32) -> Option<f32> { |
| 633 | let [w, h] = crate::outline::image_size(image)?; |
| 634 | let scale = (width / w).min(1.0); |
| 635 | image.layout.max_width = Some(w * scale); |
| 636 | image.layout.max_height = Some(h * scale); |
| 637 | Some(h * scale) |
| 638 | } |