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},
13 layout::TextEngine,
14};
15use onestore::{
16 ExGuid,
17 page::{Outline, Page, PageObject, ParagraphContent},
18};
19use std::{collections::BTreeMap, ops::Range};
20
21#[cfg(feature = "gpu")]
22use crate::editor::TextOutline;
23#[cfg(feature = "interaction")]
24use crate::editor::page::Content;
25
26#[cfg(feature = "gpu")]
27pub(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")]
38impl 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")]
54pub(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")]
73impl 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")]
248fn 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.
302pub(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.
305const GAP: f32 = 18.0;
306/// Strokes this near one another are one drawing, as handwriting is many strokes.
307const 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.
310const CANVAS_SHARE: f64 = 0.5;
311/// How far below a paragraph's top a note beside it may start and still follow it.
312const ASIDE_SLACK: f32 = 6.0;
313/// How far an aside sits in from the column's edge, and the room above and below it.
314const ASIDE_INDENT: f32 = 18.0;
315const ASIDE_GAP: f32 = 6.0;
316
317#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
318pub 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)]
328pub 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)]
341pub 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)]
353pub 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)]
366pub 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
375pub 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
383impl 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.
391pub 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.
461fn 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.
480fn 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.
515fn 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.
545type 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.
549fn 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
644fn 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.
655fn 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.
697fn 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.
746fn 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.
762fn 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.
813fn 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.
904fn 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
926fn 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.
931fn 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}