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
11use crate::{
12 editor::{CanvasEditor, EditorError, TextOutline, page::Content},
13 layout::TextEngine,
14};
15use onestore::{
16 ExGuid,
17 page::{Outline, Page, PageObject, ParagraphContent},
18};
19use std::{collections::BTreeMap, ops::Range};
20
21/// Points between blocks in the column: OneNote's grid row.
22const GAP: f32 = 18.0;
23/// Strokes this near one another are one drawing, as handwriting is many strokes.
24const 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.
27const CANVAS_SHARE: f64 = 0.5;
28/// How far below a paragraph's top a note beside it may start and still follow it.
29const ASIDE_SLACK: f32 = 6.0;
30/// How far an aside sits in from the column's edge, and the room above and below it.
31const ASIDE_INDENT: f32 = 18.0;
32const ASIDE_GAP: f32 = 6.0;
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub 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)]
45pub 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)]
58pub 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)]
70pub 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)]
83pub 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
92pub 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
100impl 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.
108pub 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.
183fn 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.
218fn 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.
248type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>);
249
250/// The page's content as laid out, overlapping pieces and nearby strokes joined.
251fn 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
345fn 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.
356fn 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.
398fn 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.
447fn 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.
463fn 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.
514fn 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.
605fn 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
627fn 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.
632fn 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}