1//! Ops against the model: for random edits of corpus pages, lowering the edit and applying
2//! the ops through a `Section` stores the edited page, as the model oracle predicts and as
3//! the sealed image reads back.
4
5use super::*;
6use crate::{
7 Arena, RevisionIndex, Section, Store,
8 document::{Document, Format, Kind, Layout},
9 page::{
10 Attachment, Definition, Image, Ink, InkStroke, Outline, PageObject, PageParagraph,
11 ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, VerticalRule,
12 text::new_id,
13 },
14 write::GUIDS,
15};
16use std::collections::{BTreeMap, BTreeSet};
17
18const SOURCES: &[(&str, &[u8])] = &[
19 (
20 "outline-edit",
21 include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"),
22 ),
23 (
24 "paragraph-edit",
25 include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one"),
26 ),
27 (
28 "outline-tree",
29 include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one"),
30 ),
31 (
32 "features",
33 include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"),
34 ),
35 (
36 "tables",
37 include_bytes!("../../../../corpus/table-edit/nested/cold/notebook/synthetic.one"),
38 ),
39 (
40 "table-controls",
41 include_bytes!("../../../../corpus/m6/native-table-controls-01/notebook/synthetic.one"),
42 ),
43 (
44 "lists",
45 include_bytes!("../../../../corpus/list-edit/cold/notebook/lists.one"),
46 ),
47 (
48 "tags",
49 include_bytes!("../../../../corpus/tag-edit/cold/notebook/tags.one"),
50 ),
51 (
52 "pictures",
53 include_bytes!("../../../../corpus/picture-edit/native-page-level/notebook/pictures.one"),
54 ),
55 (
56 "attachments",
57 include_bytes!("../../../../corpus/attachment-edit/plain/cold/notebook/files.one"),
58 ),
59 (
60 "ink",
61 include_bytes!("../../../../corpus/ink-edit/drawing/cold/notebook/ink.one"),
62 ),
63 (
64 "math",
65 include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one"),
66 ),
67 (
68 "links",
69 include_bytes!("../../../../corpus/link-edit/native-links/notebook/links.one"),
70 ),
71 (
72 "paragraph-format",
73 include_bytes!("../../../../corpus/paragraph-format/cold/notebook/synthetic.one"),
74 ),
75 (
76 "enter-probe",
77 include_bytes!("../../../../corpus/structural-probe/probe.one"),
78 ),
79];
80
81/// FILETIME both writers take as the edit's time.
82const AT: u64 = 133_700_000_000_000_000;
83
84pub(super) struct Rng(pub(super) u64);
85
86impl Rng {
87 fn next(&mut self) -> u64 {
88 self.0 = self
89 .0
90 .wrapping_mul(6364136223846793005)
91 .wrapping_add(1442695040888963407);
92 self.0 >> 33
93 }
94
95 fn coin(&mut self) -> bool {
96 self.next().is_multiple_of(2)
97 }
98
99 fn pick(&mut self, count: usize) -> Option<usize> {
100 (count > 0).then(|| self.next() as usize % count)
101 }
102
103 fn text(&mut self) -> String {
104 let words = [
105 "",
106 "a",
107 "Two words",
108 "東京 🦀",
109 "é\u{301}",
110 "longer text here",
111 " ",
112 ];
113 words[self.next() as usize % words.len()].to_owned()
114 }
115}
116
117pub(super) fn seeded<T>(seed: u64, f: impl FnOnce() -> T) -> T {
118 let outer = GUIDS.replace(Some(seed));
119 let result = f();
120 GUIDS.set(outer);
121 result
122}
123
124fn pages(image: &[u8]) -> Vec<ExGuid> {
125 let store = Store::parse(image).unwrap();
126 let index = RevisionIndex::parse(&store).unwrap();
127 let document = Document::parse(&index).unwrap();
128 document
129 .pages()
130 .unwrap()
131 .into_iter()
132 .map(|(space, _)| space)
133 .collect()
134}
135
136fn read(image: &[u8], space: ExGuid) -> Page {
137 let store = Store::parse(image).unwrap();
138 let index = RevisionIndex::parse(&store).unwrap();
139 Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
140}
141
142/// `page` with writer-chosen identities replaced in document order: list nodes, which a
143/// copy for a second owner gets (so equal definitions become one), tag field indices, and ink
144/// coordinates at the resolution they are stored in.
145pub(super) fn normalize(page: &Page) -> Page {
146 let mut page = page.clone();
147 let mut lists = BTreeMap::new();
148 let mut canonical = BTreeMap::new();
149 let mut paths: Vec<_> = model::lists(&page)
150 .into_iter()
151 .map(|(path, _, _)| path)
152 .collect();
153 paths.sort_by_key(|path| format!("{path:?}"));
154 let definitions = page.definitions.clone();
155 for path in &paths {
156 for paragraph in model::list_at(&mut page, path).iter_mut() {
157 for list in &mut paragraph.lists {
158 let next = ExGuid {
159 guid: [0xee; 16],
160 n: 1 + canonical.len() as u32,
161 };
162 let content = definitions
163 .get(list)
164 .map_or(format!("{list:?}"), |d| format!("{d:?}"));
165 let id = *canonical.entry(content).or_insert(next);
166 *list = *lists.entry(*list).or_insert(id);
167 }
168 for tag in paragraph.tags.iter_mut() {
169 tag.extra_set = 0;
170 }
171 if let Some(text) = paragraph.text_mut() {
172 for tag in &mut text.tags {
173 tag.extra_set = 0;
174 }
175 // An absent paragraph value and its default are the same formatting.
176 let mut previous = 0;
177 let runs: Vec<(String, Format)> = text
178 .text
179 .spans()
180 .iter()
181 .map(|span| {
182 let mut format = span.format.clone();
183 format.alignment = format.alignment.filter(|v| *v != 0);
184 format.rtl = format.rtl.filter(|v| *v);
185 format.space_before = format.space_before.filter(|v| *v != 0.0);
186 format.space_after = format.space_after.filter(|v| *v != 0.0);
187 format.line_spacing = format.line_spacing.filter(|v| *v != 0.0);
188 let run = (text.text.text()[previous..span.end].to_owned(), format);
189 previous = span.end;
190 run
191 })
192 .collect();
193 text.text = Paragraph::from_runs(runs);
194 }
195 }
196 }
197 page.definitions = std::mem::take(&mut page.definitions)
198 .into_iter()
199 .filter_map(|(id, definition)| match definition.kind {
200 Kind::List { .. } => lists.get(&id).map(|id| (*id, definition)),
201 _ => Some((id, definition)),
202 })
203 .collect();
204 fn quantize(ink: &mut Ink) {
205 for stroke in &mut ink.strokes {
206 for point in &mut stroke.points {
207 *point = point.map(|value| (value * 10.0).round() / 10.0);
208 }
209 }
210 ink.groups.iter_mut().for_each(quantize);
211 }
212 for object in &mut page.objects {
213 if let PageObject::Ink(ink) = object {
214 quantize(ink);
215 }
216 }
217 for path in &paths {
218 for paragraph in model::list_at(&mut page, path).iter_mut() {
219 if let ParagraphContent::Ink(ink) = &mut paragraph.content {
220 quantize(ink);
221 }
222 }
223 }
224 page
225}
226
227/// `after` with what the writers derive rather than store as given: the title from the
228/// title text, the values they give new objects where the model leaves them unset, and only
229/// the definitions something uses.
230fn as_written(after: &Page, before: &Page, stored: &Page) -> Page {
231 use serde_json::Value;
232 fn ids(value: &Value, out: &mut BTreeSet<String>) {
233 match value {
234 Value::Object(map) => {
235 if let Some(Value::String(id)) = map.get("id") {
236 out.insert(id.clone());
237 }
238 map.values().for_each(|value| ids(value, out));
239 }
240 Value::Array(values) => values.iter().for_each(|value| ids(value, out)),
241 _ => {}
242 }
243 }
244 fn adopt(after: &mut Value, stored: &Value, old: &BTreeSet<String>, new: bool) {
245 match (after, stored) {
246 (Value::Object(after), Value::Object(stored)) => {
247 let new =
248 new || matches!(after.get("id"), Some(Value::String(id)) if !old.contains(id));
249 for (key, value) in after.iter_mut() {
250 let Some(written) = stored.get(key) else {
251 continue;
252 };
253 // Ink coordinates are stored at a coarser resolution.
254 let near = |a: &Value, b: &Value| {
255 a.as_f64()
256 .zip(b.as_f64())
257 .is_some_and(|(a, b)| (a - b).abs() < 0.05)
258 };
259 let close_points = key == "points"
260 && value
261 .as_array()
262 .zip(written.as_array())
263 .is_some_and(|(a, b)| {
264 a.len() == b.len()
265 && a.iter().zip(b).all(|(a, b)| {
266 a.as_array().zip(b.as_array()).is_some_and(|(a, b)| {
267 a.len() == b.len()
268 && a.iter().zip(b).all(|(a, b)| near(a, b))
269 })
270 })
271 });
272 let unset =
273 new && (value.is_null() || value.as_array().is_some_and(Vec::is_empty));
274 if close_points || unset {
275 *value = written.clone();
276 } else {
277 adopt(value, written, old, new);
278 }
279 }
280 }
281 (Value::Array(after), Value::Array(stored)) if after.len() == stored.len() => {
282 for (value, written) in after.iter_mut().zip(stored) {
283 adopt(value, written, old, new);
284 }
285 }
286 _ => {}
287 }
288 }
289 let mut old = BTreeSet::new();
290 ids(&serde_json::to_value(before).unwrap(), &mut old);
291 let mut value = serde_json::to_value(after).unwrap();
292 adopt(
293 &mut value,
294 &serde_json::to_value(stored).unwrap(),
295 &old,
296 false,
297 );
298 let mut page: Page = serde_json::from_value(value).unwrap();
299 page.title = stored.title.clone();
300 unused_dropped(&mut page);
301 page
302}
303
304/// Drops the definitions nothing on `page` uses, as reading a page leaves them out.
305fn unused_dropped(page: &mut Page) {
306 let mut used = BTreeSet::new();
307 for (_, _, list) in model::lists(page) {
308 for paragraph in list {
309 used.extend(paragraph.style);
310 used.extend(paragraph.lists.iter().copied());
311 used.extend(paragraph.tags.iter().filter_map(|tag| tag.definition));
312 if let ParagraphContent::Table(table) = &paragraph.content {
313 used.extend(table.tags.iter().filter_map(|tag| tag.definition));
314 }
315 if let Some(text) = paragraph.text() {
316 used.extend(text.tags.iter().filter_map(|tag| tag.definition));
317 }
318 }
319 }
320 page.definitions.retain(|id, _| used.contains(id));
321}
322
323#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
324pub(super) enum Family {
325 Text,
326 Format,
327 Insert,
328 Paste,
329 Delete,
330 Move,
331 Collapse,
332 Position,
333 Width,
334 NewOutline,
335 List,
336 Tag,
337 Split,
338 Join,
339 Indent,
340 Outdent,
341 Alignment,
342 Style,
343 TableRow,
344 TableColumn,
345 TableDelete,
346 TableWidth,
347 TableShading,
348 NewTable,
349 PagePicture,
350 PictureEdit,
351 ParagraphPicture,
352 Attachment,
353 AttachmentRename,
354 NewInk,
355 InkStrokes,
356 InkPosition,
357 Link,
358 Equation,
359}
360
361pub(super) const FAMILIES: [Family; 34] = [
362 Family::Text,
363 Family::Format,
364 Family::Insert,
365 Family::Paste,
366 Family::Delete,
367 Family::Move,
368 Family::Collapse,
369 Family::Position,
370 Family::Width,
371 Family::NewOutline,
372 Family::List,
373 Family::Tag,
374 Family::Split,
375 Family::Join,
376 Family::Indent,
377 Family::Outdent,
378 Family::Alignment,
379 Family::Style,
380 Family::TableRow,
381 Family::TableColumn,
382 Family::TableDelete,
383 Family::TableWidth,
384 Family::TableShading,
385 Family::NewTable,
386 Family::PagePicture,
387 Family::PictureEdit,
388 Family::ParagraphPicture,
389 Family::Attachment,
390 Family::AttachmentRename,
391 Family::NewInk,
392 Family::InkStrokes,
393 Family::InkPosition,
394 Family::Link,
395 Family::Equation,
396];
397
398const PNG: &[u8] = &[
399 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
400 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
401 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
402 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
403 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
404];
405
406fn id() -> ExGuid {
407 new_id().unwrap()
408}
409
410/// `ops` on page `space` of `source` as one edit, sealed: the image they leave.
411pub(crate) fn edited(source: &[u8], space: ExGuid, ops: Vec<PageOp>) -> Result<Vec<u8>, OpError> {
412 let arena = Arena::default();
413 let mut section = Section::open(&arena, source.to_vec()).map_err(OpError::Failed)?;
414 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
415 section.apply("Author", &Edit { at: AT, ops })?;
416 section.seal().map_err(OpError::Failed)?;
417 Ok(section.image())
418}
419
420pub(crate) fn text_paragraph(text: &str, format: Format) -> PageParagraph {
421 PageParagraph {
422 id: id(),
423 parent: None,
424 level: 1,
425 style: None,
426 format: Format::default(),
427 content: ParagraphContent::Text(TextObject {
428 id: id(),
429 date_field: None,
430 text: Paragraph::new(text.to_owned(), format),
431 tags: Vec::new(),
432 }),
433 lists: Vec::new(),
434 tags: Vec::new(),
435 media: Default::default(),
436 collapsed: false,
437 }
438}
439
440/// Paths of the page's editable lists: ordinary outlines and their cells.
441fn editable(page: &Page) -> Vec<model::Path> {
442 let titles: Vec<ExGuid> = page
443 .objects
444 .iter()
445 .filter_map(|o| match o {
446 PageObject::Title(title) => {
447 Some(title.outlines.iter().map(|o| o.id).collect::<Vec<_>>())
448 }
449 _ => None,
450 })
451 .flatten()
452 .collect();
453 model::lists(page)
454 .into_iter()
455 .filter(|(_, owner, list)| !titles.contains(owner) && !list.is_empty())
456 .map(|(path, _, _)| path)
457 .collect()
458}
459
460/// A text paragraph of an editable list: its list and index.
461fn pick_text(page: &Page, rng: &mut Rng) -> Option<(model::Path, usize)> {
462 let candidates: Vec<(model::Path, usize)> = editable(page)
463 .into_iter()
464 .flat_map(|path| {
465 let list = model::lists(page)
466 .into_iter()
467 .find(|(p, _, _)| format!("{p:?}") == format!("{path:?}"))
468 .unwrap()
469 .2
470 .clone();
471 list.iter()
472 .enumerate()
473 .filter(|(_, p)| {
474 p.text().is_some_and(|t| {
475 t.date_field.is_none() && !crate::page::Math::is_equation(&t.text)
476 })
477 })
478 .map(|(i, _)| (path.clone(), i))
479 .collect::<Vec<_>>()
480 })
481 .collect();
482 candidates.get(rng.pick(candidates.len())?).cloned()
483}
484
485fn outlines(page: &mut Page) -> Vec<&mut Outline> {
486 page.objects
487 .iter_mut()
488 .filter_map(|o| match o {
489 PageObject::Outline(outline) => Some(outline),
490 _ => None,
491 })
492 .collect()
493}
494
495fn subtree_end(list: &[PageParagraph], index: usize) -> usize {
496 let mut members = vec![list[index].id];
497 let mut end = index + 1;
498 while end < list.len() && list[end].parent.is_some_and(|p| members.contains(&p)) {
499 members.push(list[end].id);
500 end += 1;
501 }
502 end
503}
504
505fn tables(page: &mut Page) -> Vec<&mut Table> {
506 let mut out = Vec::new();
507 for outline in outlines(page) {
508 for paragraph in &mut outline.paragraphs {
509 if let ParagraphContent::Table(table) = &mut paragraph.content {
510 out.push(table);
511 }
512 }
513 }
514 out
515}
516
517fn new_cell(indents: &[f32]) -> TableCell {
518 TableCell {
519 id: id(),
520 layout: Layout::default(),
521 indents: indents.to_vec(),
522 shading: None,
523 paragraphs: vec![text_paragraph("cell", Format::default())],
524 unsupported: Vec::new(),
525 }
526}
527
528/// Applies one random edit of `family` to `page`; false when the page offers none.
529pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
530 match family {
531 Family::Text | Family::Format | Family::Link => {
532 let Some((path, index)) = pick_text(page, rng) else {
533 return false;
534 };
535 let text = &mut model::list_at(page, &path)[index].text_mut().unwrap().text;
536 let end = text.utf16_offset(text.text().len()).unwrap();
537 let chars: Vec<u32> = text
538 .text()
539 .char_indices()
540 .map(|(i, _)| text.utf16_offset(i).unwrap())
541 .chain([end])
542 .collect();
543 let a = chars[rng.pick(chars.len()).unwrap()];
544 let b = chars[rng.pick(chars.len()).unwrap()];
545 let range = a.min(b)..a.max(b);
546 match family {
547 Family::Text => {
548 let format = text.format_at(range.start).unwrap().clone();
549 text.apply(crate::page::text::Edit {
550 range,
551 replacement: Paragraph::new(rng.text(), format),
552 })
553 .is_ok()
554 }
555 Family::Format => {
556 if range.is_empty() {
557 return false;
558 }
559 let slice = text.slice(range.clone()).unwrap();
560 let attribute = rng.next() % 6;
561 let mut from = 0;
562 let runs: Vec<(String, Format)> = slice
563 .spans()
564 .iter()
565 .map(|span| {
566 let mut format = span.format.clone();
567 match attribute {
568 0 => format.bold = Some(!format.bold.unwrap_or(false)),
569 1 => format.italic = Some(!format.italic.unwrap_or(false)),
570 2 => format.underline = Some(!format.underline.unwrap_or(false)),
571 3 => {
572 format.font_size = Some(if format.font_size == Some(14.0) {
573 11.0
574 } else {
575 14.0
576 })
577 }
578 4 => format.color = Some(0x00ff),
579 _ => format.font = Some("Consolas".into()),
580 }
581 let run = (slice.text()[from..span.end].to_owned(), format);
582 from = span.end;
583 run
584 })
585 .collect();
586 text.apply(crate::page::text::Edit {
587 range,
588 replacement: Paragraph::from_runs(runs),
589 })
590 .is_ok()
591 }
592 _ => {
593 if range.is_empty()
594 || text
595 .spans()
596 .iter()
597 .any(|s| s.format.hyperlink == Some(true))
598 {
599 return false;
600 }
601 let base = text.format_at(range.start).unwrap().clone();
602 let label = text.slice(range.clone()).unwrap();
603 let mut code = base.clone();
604 code.hyperlink = Some(true);
605 code.hyperlink_label = Some(true);
606 code.hidden = Some(true);
607 let mut from = 0;
608 let mut runs = vec![(
609 "\u{fddf}HYPERLINK \"https://example.invalid/op\"".to_owned(),
610 code,
611 )];
612 for span in label.spans() {
613 let mut format = span.format.clone();
614 format.hyperlink = Some(true);
615 format.hyperlink_label = Some(true);
616 runs.push((label.text()[from..span.end].to_owned(), format));
617 from = span.end;
618 }
619 text.apply(crate::page::text::Edit {
620 range,
621 replacement: Paragraph::from_runs(runs),
622 })
623 .is_ok()
624 }
625 }
626 }
627 Family::Insert | Family::Paste | Family::Style => {
628 let paths = editable(page);
629 let Some(path) = paths.get(rng.pick(paths.len()).unwrap_or(0)).cloned() else {
630 return false;
631 };
632 let style = (family == Family::Style).then(|| {
633 let existing = page.definitions.iter().find_map(|(id, d)| {
634 matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "Heading 2")
635 .then_some(*id)
636 });
637 existing.unwrap_or_else(|| {
638 let id = id();
639 page.definitions.insert(
640 id,
641 Definition {
642 kind: Kind::Style {
643 name: Some("Heading 2".into()),
644 next: None,
645 },
646 format: Format {
647 font: Some("Calibri".into()),
648 font_size: Some(13.0),
649 bold: Some(true),
650 color: Some(0x7d4f1e),
651 ..Default::default()
652 },
653 },
654 );
655 id
656 })
657 });
658 let list = model::list_at(page, &path);
659 let template = list
660 .iter()
661 .find_map(|p| p.text().map(|t| t.text.format_at(0).unwrap().clone()))
662 .unwrap_or_default();
663 let boundaries: Vec<usize> = (0..=list.len())
664 .filter(|i| list.get(*i).is_none_or(|p| p.parent.is_none()))
665 .collect();
666 let at = boundaries[rng.pick(boundaries.len()).unwrap()];
667 let count = if family == Family::Paste {
668 2 + rng.next() as usize % 3
669 } else {
670 1
671 };
672 let mut inserted = Vec::new();
673 for i in 0..count {
674 let mut paragraph = text_paragraph(&rng.text(), template.clone());
675 if let Some(style) = style {
676 paragraph.style = Some(style);
677 }
678 if i > 0 && rng.coin() {
679 let parent: &PageParagraph = &inserted[0];
680 paragraph.parent = Some(parent.id);
681 paragraph.level = parent.level + 1;
682 }
683 inserted.push(paragraph);
684 }
685 // Children follow their parent, the first inserted paragraph, directly.
686 let (children, mut inserted): (Vec<_>, Vec<_>) =
687 inserted.into_iter().partition(|p| p.parent.is_some());
688 inserted.splice(1..1, children);
689 let cell_level = list.first().map_or(1, |p| p.level);
690 for paragraph in &mut inserted {
691 if paragraph.parent.is_none() {
692 paragraph.level = cell_level;
693 }
694 }
695 list.splice(at..at, inserted);
696 true
697 }
698 Family::Delete | Family::Move | Family::Collapse => {
699 let paths = editable(page);
700 let Some(path) = paths.get(rng.pick(paths.len()).unwrap_or(0)).cloned() else {
701 return false;
702 };
703 let list = model::list_at(page, &path);
704 let top: Vec<usize> = (0..list.len())
705 .filter(|i| list[*i].parent.is_none())
706 .collect();
707 match family {
708 Family::Delete => {
709 if top.len() < 2 {
710 return false;
711 }
712 let at = top[rng.pick(top.len()).unwrap()];
713 let end = subtree_end(list, at);
714 list.drain(at..end);
715 true
716 }
717 Family::Move => {
718 if top.len() < 2 {
719 return false;
720 }
721 let from = top[rng.pick(top.len()).unwrap()];
722 let end = subtree_end(list, from);
723 let block: Vec<_> = list.drain(from..end).collect();
724 let boundaries: Vec<usize> = (0..=list.len())
725 .filter(|i| list.get(*i).is_none_or(|p| p.parent.is_none()))
726 .collect();
727 let to = boundaries[rng.pick(boundaries.len()).unwrap()];
728 list.splice(to..to, block);
729 true
730 }
731 _ => {
732 let at = rng.pick(list.len()).unwrap();
733 list[at].collapsed ^= true;
734 true
735 }
736 }
737 }
738 Family::Position | Family::Width => {
739 let mut outlines: Vec<&mut Outline> =
740 outlines(page).into_iter().filter(|o| !o.title).collect();
741 let Some(at) = rng.pick(outlines.len()) else {
742 return false;
743 };
744 let outline = &mut outlines[at];
745 if family == Family::Position {
746 outline.layout.x = Some((rng.next() % 200) as f32 * 1.5);
747 outline.layout.y = Some((rng.next() % 200) as f32 * 2.25);
748 } else {
749 outline.layout.max_width = Some(36.0 + (rng.next() % 200) as f32 * 2.0);
750 outline.layout.width_set_by_user = Some(rng.coin());
751 }
752 true
753 }
754 Family::NewOutline => {
755 let at = page
756 .objects
757 .iter()
758 .position(|o| matches!(o, PageObject::Title(_)))
759 .unwrap_or(page.objects.len());
760 let at = rng.pick(at + 1).unwrap();
761 page.objects.insert(
762 at,
763 PageObject::Outline(Outline {
764 id: id(),
765 title: false,
766 min_width: None,
767 layout: Layout {
768 x: Some((rng.next() % 100) as f32 * 1.5),
769 y: Some((rng.next() % 100) as f32 * 2.25),
770 ..Default::default()
771 },
772 indents: Vec::new(),
773 paragraphs: vec![text_paragraph(&rng.text(), Format::default())],
774 unsupported: Vec::new(),
775 }),
776 );
777 true
778 }
779 Family::List | Family::Tag | Family::Alignment => {
780 let Some((path, index)) = pick_text(page, rng) else {
781 return false;
782 };
783 match family {
784 Family::List => {
785 let paragraph = &model::list_at(page, &path)[index];
786 if paragraph.lists.is_empty() {
787 let list = id();
788 page.definitions.insert(
789 list,
790 Definition {
791 kind: Kind::List {
792 font: Some("Courier New".into()),
793 format: Some("\u{25cb}".into()),
794 restart: None,
795 bullet: Some(4),
796 },
797 format: Format {
798 font_size: Some(11.0),
799 color: Some(0xff000000),
800 ..Default::default()
801 },
802 },
803 );
804 model::list_at(page, &path)[index].lists = vec![list];
805 } else {
806 model::list_at(page, &path)[index].lists.clear();
807 }
808 }
809 Family::Tag => {
810 let definition = page
811 .definitions
812 .iter()
813 .find(|(_, d)| matches!(&d.kind, Kind::TagDefinition { label: Some(l), .. } if l == "Op task"))
814 .map(|(id, _)| *id)
815 .unwrap_or_else(|| {
816 let id = id();
817 page.definitions.insert(
818 id,
819 Definition {
820 kind: Kind::TagDefinition {
821 label: Some("Op task".into()),
822 action_type: Some(0),
823 shape: Some(3),
824 color: None,
825 highlight: None,
826 },
827 format: Default::default(),
828 },
829 );
830 id
831 });
832 let status = (rng.next() % 2) as u16;
833 let text = model::list_at(page, &path)[index].text_mut().unwrap();
834 if text.tags.is_empty() {
835 text.tags.push(crate::document::Tag {
836 definition: Some(definition),
837 action_type: None,
838 shape: None,
839 property_status: None,
840 status,
841 created: Some(1_262_401_445),
842 completed: (status == 1).then_some(1_262_402_000),
843 start: None,
844 due: None,
845 task_id: None,
846 extra_set: 0,
847 });
848 } else {
849 text.tags.clear();
850 }
851 }
852 _ => {
853 let alignment = (rng.next() % 3) as u8;
854 let text = &mut model::list_at(page, &path)[index].text_mut().unwrap().text;
855 let mut from = 0;
856 let runs: Vec<(String, Format)> = text
857 .spans()
858 .iter()
859 .map(|span| {
860 let mut format = span.format.clone();
861 format.alignment = Some(alignment);
862 let run = (text.text()[from..span.end].to_owned(), format);
863 from = span.end;
864 run
865 })
866 .collect();
867 *text = Paragraph::from_runs(runs);
868 }
869 }
870 true
871 }
872 Family::Split | Family::Join => {
873 let Some((path, index)) = pick_text(page, rng) else {
874 return false;
875 };
876 let list = model::list_at(page, &path);
877 if family == Family::Split {
878 let paragraph = &list[index];
879 let text = &paragraph.text().unwrap().text;
880 let end = text.utf16_offset(text.text().len()).unwrap();
881 if text
882 .spans()
883 .iter()
884 .any(|s| s.format.hyperlink == Some(true))
885 {
886 return false;
887 }
888 let offsets: Vec<u32> = text
889 .text()
890 .char_indices()
891 .map(|(i, _)| text.utf16_offset(i).unwrap())
892 .chain([end])
893 .collect();
894 let at = offsets[rng.pick(offsets.len()).unwrap()];
895 let (head, tail) = (text.slice(0..at).unwrap(), text.slice(at..end).unwrap());
896 let mut right = paragraph.clone();
897 right.id = id();
898 right.tags.clear();
899 right.media = Default::default();
900 right.content = ParagraphContent::Text(TextObject {
901 id: id(),
902 date_field: None,
903 text: tail,
904 tags: Vec::new(),
905 });
906 let left_id = paragraph.id;
907 list[index].text_mut().unwrap().text = head;
908 for child in &mut list[index + 1..] {
909 if child.parent == Some(left_id) {
910 child.parent = Some(right.id);
911 }
912 }
913 list.insert(index + 1, right);
914 true
915 } else {
916 let next = index + 1;
917 let Some(lower) = list.get(next) else {
918 return false;
919 };
920 let upper = &list[index];
921 if lower.parent != upper.parent
922 || lower.level != upper.level
923 || list
924 .iter()
925 .any(|p| p.parent == Some(upper.id) || p.parent == Some(lower.id))
926 {
927 return false;
928 }
929 let Some(lower_text) = lower.text().cloned() else {
930 return false;
931 };
932 if lower_text.text.text().is_empty()
933 || crate::page::Math::is_equation(&lower_text.text)
934 {
935 return false;
936 }
937 let upper = list[index].text_mut().unwrap();
938 if upper.text.text().is_empty() {
939 upper.id = lower_text.id;
940 upper.text = lower_text.text;
941 } else {
942 upper.text.append(lower_text.text).unwrap();
943 }
944 list.remove(next);
945 true
946 }
947 }
948 Family::Indent | Family::Outdent => {
949 let paths = editable(page);
950 let Some(path) = paths.get(rng.pick(paths.len()).unwrap_or(0)).cloned() else {
951 return false;
952 };
953 if !format!("{path:?}").contains("cells: []") {
954 return false;
955 }
956 let list = model::list_at(page, &path);
957 let Some(index) = rng.pick(list.len()) else {
958 return false;
959 };
960 let end = subtree_end(list, index);
961 if family == Family::Indent {
962 // Tab: the paragraph becomes the last child of its previous sibling.
963 let parent = list[index].parent;
964 let Some(sibling) = list[..index]
965 .iter()
966 .rev()
967 .find(|p| p.parent == parent)
968 .map(|p| (p.id, p.level))
969 else {
970 return false;
971 };
972 list[index].parent = Some(sibling.0);
973 let delta = sibling.1 + 1 - list[index].level;
974 if delta > 1 || list[index].level + 1 > 31 {
975 return false;
976 }
977 for paragraph in &mut list[index..end] {
978 paragraph.level += 1;
979 }
980 list[index].level = list[index].level.max(sibling.1 + 1);
981 } else {
982 let Some(parent) = list[index].parent else {
983 return false;
984 };
985 // Shift-Tab of a last child: it follows its parent.
986 if list[end..].iter().any(|p| p.parent == Some(parent)) {
987 return false;
988 }
989 let grandparent = list.iter().find(|p| p.id == parent).unwrap().parent;
990 list[index].parent = grandparent;
991 for paragraph in &mut list[index..end] {
992 paragraph.level -= 1;
993 }
994 if list[index].level == 0 {
995 return false;
996 }
997 }
998 true
999 }
1000 Family::TableRow
1001 | Family::TableColumn
1002 | Family::TableDelete
1003 | Family::TableWidth
1004 | Family::TableShading => {
1005 let mut tables = tables(page);
1006 let Some(at) = rng.pick(tables.len()) else {
1007 return false;
1008 };
1009 let table = &mut tables[at];
1010 let indents = table.rows[0].cells[0].indents.clone();
1011 match family {
1012 Family::TableRow => {
1013 let row = TableRow {
1014 id: id(),
1015 cells: table.columns.iter().map(|_| new_cell(&indents)).collect(),
1016 };
1017 let at = rng.pick(table.rows.len() + 1).unwrap();
1018 table.rows.insert(at, row);
1019 }
1020 Family::TableColumn => {
1021 let at = rng.pick(table.columns.len() + 1).unwrap();
1022 table.columns.insert(
1023 at,
1024 TableColumn {
1025 width: 72.0,
1026 locked: false,
1027 },
1028 );
1029 for row in &mut table.rows {
1030 row.cells.insert(at, new_cell(&indents));
1031 }
1032 }
1033 Family::TableDelete => {
1034 if rng.coin() && table.rows.len() > 1 {
1035 let at = rng.pick(table.rows.len()).unwrap();
1036 table.rows.remove(at);
1037 } else if table.columns.len() > 1 {
1038 let at = rng.pick(table.columns.len()).unwrap();
1039 table.columns.remove(at);
1040 for row in &mut table.rows {
1041 row.cells.remove(at);
1042 }
1043 } else {
1044 return false;
1045 }
1046 }
1047 Family::TableWidth => {
1048 let at = rng.pick(table.columns.len()).unwrap();
1049 table.columns[at].width = 40.0 + (rng.next() % 100) as f32;
1050 table.columns[at].locked = rng.coin();
1051 }
1052 _ => {
1053 let cells: Vec<_> = table.rows.iter_mut().flat_map(|r| &mut r.cells).collect();
1054 let len = cells.len();
1055 let cell = cells.into_iter().nth(rng.pick(len).unwrap()).unwrap();
1056 cell.shading = if cell.shading.is_some() {
1057 None
1058 } else {
1059 Some(0x00ccff)
1060 };
1061 }
1062 }
1063 true
1064 }
1065 Family::NewTable | Family::ParagraphPicture | Family::Attachment | Family::Equation => {
1066 let equation = page.objects.iter().find_map(|o| match o {
1067 PageObject::Outline(outline) => outline.paragraphs.iter().find_map(|p| {
1068 p.text()
1069 .filter(|t| crate::page::Math::is_equation(&t.text))
1070 .map(|t| t.text.clone())
1071 }),
1072 _ => None,
1073 });
1074 let Some(outline) = outlines(page).into_iter().find(|o| !o.title) else {
1075 return false;
1076 };
1077 let mut paragraph = text_paragraph("", Format::default());
1078 paragraph.content = match family {
1079 Family::NewTable => ParagraphContent::Table(Table {
1080 id: id(),
1081 columns: vec![
1082 TableColumn {
1083 width: 72.0,
1084 locked: false
1085 };
1086 2
1087 ],
1088 rows: (0..2)
1089 .map(|_| TableRow {
1090 id: id(),
1091 cells: (0..2).map(|_| new_cell(&[])).collect(),
1092 })
1093 .collect(),
1094 borders: Some(true),
1095 layout: Layout::default(),
1096 tags: Vec::new(),
1097 }),
1098 Family::ParagraphPicture => ParagraphContent::Image(Image {
1099 id: id(),
1100 layout: Layout::default(),
1101 size: Some([0.75, 0.75]),
1102 bytes: Some(PNG.into()),
1103 display: None,
1104 alt: Some("dot".into()),
1105 background: false,
1106 printout: None,
1107 tags: Vec::new(),
1108 link: None,
1109 text: None,
1110 }),
1111 Family::Attachment => ParagraphContent::Attachment(Attachment {
1112 id: id(),
1113 filename: "notes.txt".into(),
1114 source_path: Some(r"C:\notes.txt".into()),
1115 size: None,
1116 layout: Layout::default(),
1117 bytes: Some(b"attached bytes".as_slice().into()),
1118 preview: None,
1119 recording: None,
1120 tags: Vec::new(),
1121 }),
1122 _ => {
1123 let Some(equation) = equation else {
1124 return false;
1125 };
1126 ParagraphContent::Text(TextObject {
1127 id: id(),
1128 date_field: None,
1129 text: equation,
1130 tags: Vec::new(),
1131 })
1132 }
1133 };
1134 let boundaries: Vec<usize> = (0..=outline.paragraphs.len())
1135 .filter(|i| {
1136 outline
1137 .paragraphs
1138 .get(*i)
1139 .is_none_or(|p| p.parent.is_none())
1140 })
1141 .collect();
1142 let at = boundaries[rng.pick(boundaries.len()).unwrap()];
1143 outline.paragraphs.insert(at, paragraph);
1144 true
1145 }
1146 Family::PagePicture | Family::NewInk => {
1147 let at = page
1148 .objects
1149 .iter()
1150 .position(|o| matches!(o, PageObject::Title(_)))
1151 .unwrap_or(page.objects.len());
1152 let object = if family == Family::PagePicture {
1153 PageObject::Image(Image {
1154 id: id(),
1155 layout: Layout {
1156 x: Some(100.0),
1157 y: Some(200.0),
1158 ..Default::default()
1159 },
1160 size: Some([0.75, 0.75]),
1161 bytes: Some(PNG.into()),
1162 display: None,
1163 alt: None,
1164 background: false,
1165 printout: None,
1166 tags: Vec::new(),
1167 link: None,
1168 text: None,
1169 })
1170 } else {
1171 PageObject::Ink(Ink {
1172 id: id(),
1173 layout: Layout::default(),
1174 strokes: vec![stroke(rng)],
1175 groups: Vec::new(),
1176 shape: None,
1177 })
1178 };
1179 page.objects.insert(at, object);
1180 true
1181 }
1182 Family::PictureEdit => {
1183 let images: Vec<&mut Image> = page
1184 .objects
1185 .iter_mut()
1186 .filter_map(|o| match o {
1187 PageObject::Image(image) => Some(image),
1188 _ => None,
1189 })
1190 .collect();
1191 let len = images.len();
1192 let Some(image) = images.into_iter().nth(rng.pick(len).unwrap_or(0)) else {
1193 return false;
1194 };
1195 match rng.next() % 3 {
1196 0 => {
1197 image.layout.x = Some((rng.next() % 300) as f32);
1198 image.layout.y = Some((rng.next() % 300) as f32);
1199 }
1200 1 => {
1201 image.layout.max_width = Some(20.0 + (rng.next() % 100) as f32);
1202 image.layout.max_height = Some(20.0 + (rng.next() % 100) as f32);
1203 image.layout.width_set_by_user = Some(true);
1204 }
1205 _ => image.alt = Some(rng.text()),
1206 }
1207 true
1208 }
1209 Family::AttachmentRename => {
1210 let mut found = false;
1211 for outline in outlines(page) {
1212 for paragraph in &mut outline.paragraphs {
1213 if let ParagraphContent::Attachment(attachment) = &mut paragraph.content
1214 && !found
1215 && attachment.recording.is_none()
1216 {
1217 attachment.filename = format!("renamed {}.txt", rng.next() % 100);
1218 found = true;
1219 }
1220 }
1221 }
1222 found
1223 }
1224 Family::InkStrokes => {
1225 let inks: Vec<&mut Ink> = page
1226 .objects
1227 .iter_mut()
1228 .filter_map(|o| match o {
1229 PageObject::Ink(ink) if ink.groups.is_empty() => Some(ink),
1230 _ => None,
1231 })
1232 .collect();
1233 let len = inks.len();
1234 let Some(ink) = inks.into_iter().nth(rng.pick(len).unwrap_or(0)) else {
1235 return false;
1236 };
1237 if rng.coin() || ink.strokes.len() < 2 {
1238 ink.strokes.push(stroke(rng));
1239 } else {
1240 let at = rng.pick(ink.strokes.len()).unwrap();
1241 ink.strokes.remove(at);
1242 }
1243 true
1244 }
1245 Family::InkPosition => {
1246 let Some(ink) = page.objects.iter_mut().find_map(|o| match o {
1247 PageObject::Ink(ink) => Some(ink),
1248 _ => None,
1249 }) else {
1250 return false;
1251 };
1252 ink.layout.x = Some((rng.next() % 40) as f32 - 20.0);
1253 ink.layout.y = Some((rng.next() % 90) as f32);
1254 true
1255 }
1256 }
1257}
1258
1259fn stroke(rng: &mut Rng) -> InkStroke {
1260 let x = (rng.next() % 300) as f32;
1261 InkStroke {
1262 id: id(),
1263 points: vec![[x, 100.0], [x + 10.0, 110.0], [x + 20.0, 105.0]],
1264 width: 1.5,
1265 height: 1.5,
1266 color: Some(0x0000ff),
1267 transparency: None,
1268 pen_tip: None,
1269 // Every other stroke a highlighter's, and every third a pressure pen's.
1270 raster_operation: (x as u32).is_multiple_of(2).then_some(9),
1271 pressure: if (x as u32).is_multiple_of(3) {
1272 [0.0, 0.3, 1.0].map(crate::page::ink::level).to_vec()
1273 } else {
1274 Vec::new()
1275 },
1276 }
1277}
1278
1279/// How one lowered edit came out.
1280#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
1281pub(super) enum Outcome {
1282 /// The edit made no change the writers store.
1283 Unchanged,
1284 /// Lowering or the section refused the edit; the section is as it was.
1285 Refused,
1286 /// The sealed image reads back as the edited page.
1287 Stored,
1288 Mismatch(String),
1289}
1290
1291/// Lowers `after` over page `space` of `source`, applies and seals the ops, and compares
1292/// the page the section holds with the edited page, with the model oracle's prediction and
1293/// with the page the sealed image reads back as.
1294pub(super) fn stored(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> Outcome {
1295 let before = read(source, space);
1296 let arena = Arena::default();
1297 let mut section = Section::open(&arena, source.to_vec()).unwrap();
1298 let Ok(ops) = lower_page(&before, after) else {
1299 return Outcome::Refused;
1300 };
1301 let edit = Edit {
1302 at: AT,
1303 ops: ops
1304 .iter()
1305 .map(|op| Op::Page {
1306 space,
1307 op: op.clone(),
1308 })
1309 .collect(),
1310 };
1311 if seeded(seed, || section.apply("Author", &edit)).is_err() {
1312 if section.page(space).unwrap() != before {
1313 return Outcome::Mismatch("a refused edit changed the page".into());
1314 }
1315 return Outcome::Refused;
1316 }
1317 let mut predicted = before.clone();
1318 for op in &ops {
1319 model::apply(&mut predicted, op).unwrap();
1320 }
1321 let live = section.page(space).unwrap();
1322 if normalize(&predicted) != normalize(&live) {
1323 return Outcome::Mismatch(format!(
1324 "the model predicts otherwise: {}",
1325 first_difference(&normalize(&live), &normalize(&predicted))
1326 ));
1327 }
1328 let Some(_) = seeded(seed, || section.seal().unwrap()) else {
1329 return if live == before {
1330 Outcome::Unchanged
1331 } else {
1332 Outcome::Mismatch("the page changed and the seal stored nothing".into())
1333 };
1334 };
1335 let image = section.image();
1336 let store = Store::parse(&image).unwrap();
1337 let index = RevisionIndex::parse(&store).unwrap();
1338 if let Err(error) = index.validate_current() {
1339 return Outcome::Mismatch(format!("invalid image: {error:?}"));
1340 }
1341 let reread = read(&image, space);
1342 if reread != live {
1343 return Outcome::Mismatch(format!(
1344 "reads back otherwise: {}",
1345 first_difference(&live, &reread)
1346 ));
1347 }
1348 let written = as_written(after, &before, &reread);
1349 let (stored, wanted) = (normalize(&reread), normalize(&written));
1350 if stored != wanted {
1351 return Outcome::Mismatch(format!(
1352 "stores another page: {}",
1353 first_difference(&wanted, &stored)
1354 ));
1355 }
1356 Outcome::Stored
1357}
1358
1359fn spans(text: &Paragraph) -> String {
1360 let mut out = format!("{:?}:", text.text());
1361 for span in text.spans() {
1362 let f = &span.format;
1363 out.push_str(&format!(
1364 " [{} b{:?} h{:?} l{:?}/{:?} c{:?} a{:?} s{:?} lang{:?} f{:?}]",
1365 span.end,
1366 f.bold,
1367 f.hidden,
1368 f.hyperlink,
1369 f.hyperlink_label,
1370 f.color,
1371 f.alignment,
1372 f.space_before,
1373 f.language,
1374 f.font
1375 ));
1376 }
1377 out
1378}
1379
1380pub(super) fn first_difference(expected: &Page, actual: &Page) -> String {
1381 let texts = |page: &Page| -> Vec<(ExGuid, Paragraph)> {
1382 model::lists(page)
1383 .into_iter()
1384 .flat_map(|(_, _, list)| list.iter())
1385 .filter_map(|p| p.text().map(|t| (p.id, t.text.clone())))
1386 .collect()
1387 };
1388 for ((a_id, a), (b_id, b)) in texts(expected).iter().zip(texts(actual).iter()) {
1389 if a_id == b_id && a != b {
1390 return format!("\n expected {}\n actual {}", spans(a), spans(b));
1391 }
1392 }
1393 let (a, b) = (format!("{expected:?}"), format!("{actual:?}"));
1394 let at = a
1395 .bytes()
1396 .zip(b.bytes())
1397 .position(|(x, y)| x != y)
1398 .unwrap_or(a.len().min(b.len()));
1399 let window = |s: &str| {
1400 let start = s.floor_char_boundary(at.saturating_sub(300));
1401 let end = s.floor_char_boundary((at + 200).min(s.len()));
1402 s[start..end].to_owned()
1403 };
1404 format!("\n expected …{}\n actual …{}", window(&a), window(&b))
1405}
1406
1407/// Every family on every corpus page it applies to, several seeds each.
1408#[test]
1409fn lowered_edits_store_the_edited_page() {
1410 let mut tally: BTreeMap<(Family, String), usize> = BTreeMap::new();
1411 let mut mismatches = Vec::new();
1412 for (name, source) in SOURCES {
1413 for (p, space) in pages(source).into_iter().enumerate().take(4) {
1414 for family in FAMILIES {
1415 for seed in crate::sweep::seeds(0..3, 1) {
1416 let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64);
1417 let mut after = read(source, space);
1418 GUIDS.set(Some(1 << 60 | seed << 32 | (family as u64) << 20));
1419 if !mutate(&mut after, &mut rng, family) {
1420 continue;
1421 }
1422 GUIDS.set(None);
1423 let outcome = stored(source, space, &after, 1 << 40 | seed);
1424 let key = match &outcome {
1425 Outcome::Mismatch(_) => "Mismatch".to_owned(),
1426 other => format!("{other:?}"),
1427 };
1428 *tally.entry((family, key)).or_default() += 1;
1429 if let Outcome::Mismatch(detail) = outcome {
1430 mismatches
1431 .push(format!("{name} page {p} {family:?} seed {seed}: {detail}"));
1432 }
1433 }
1434 }
1435 }
1436 }
1437 let mut report = String::new();
1438 for ((family, outcome), count) in &tally {
1439 report.push_str(&format!("{family:?} {outcome} {count}\n"));
1440 }
1441 println!("{report}");
1442 for mismatch in mismatches.iter().take(8) {
1443 println!("{mismatch}\n");
1444 }
1445 assert!(mismatches.is_empty(), "{} mismatches", mismatches.len());
1446}
1447
1448#[test]
1449#[ignore]
1450fn debug_case() {
1451 let (_, source) = SOURCES[5];
1452 let space = pages(source)[1];
1453 let family = Family::NewOutline;
1454 let (seed, p) = (1u64, 1u64);
1455 let mut rng = Rng(seed * 7919 + p * 131 + family as u64);
1456 let mut after = read(source, space);
1457 GUIDS.set(Some(1 << 60 | seed << 32 | (family as u64) << 20));
1458 assert!(mutate(&mut after, &mut rng, family));
1459 GUIDS.set(None);
1460 let before = read(source, space);
1461 let ops = lower_page(&before, &after).unwrap();
1462 let mut page = before.clone();
1463 for op in &ops {
1464 model::apply(&mut page, op).unwrap();
1465 }
1466 for object in &page.objects {
1467 println!(
1468 "{:?} {:?} {:?}",
1469 object.id(),
1470 object.layout(),
1471 match object {
1472 PageObject::Outline(o) => o
1473 .paragraphs
1474 .iter()
1475 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
1476 .collect::<Vec<_>>(),
1477 PageObject::Title(t) => t
1478 .outlines
1479 .iter()
1480 .flat_map(|o| &o.paragraphs)
1481 .filter_map(|p| p.text().map(|t| format!("title {:?}", t.text.text())))
1482 .collect(),
1483 _ => vec![],
1484 }
1485 );
1486 }
1487 println!("{:?} -> {:?}", before.title, page.title);
1488}
1489
1490/// Random ops drawn from a page model: targets the page holds, new identities fresh.
1491fn random_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
1492 let texts: Vec<(ExGuid, ExGuid, Paragraph)> = model::lists(page)
1493 .into_iter()
1494 .flat_map(|(_, _, list)| list.iter())
1495 .filter_map(|p| {
1496 let text = p.text()?;
1497 (text.date_field.is_none() && !crate::page::Math::is_equation(&text.text))
1498 .then(|| (p.id, text.id, text.text.clone()))
1499 })
1500 .collect();
1501 let (paragraph, text, content) = texts.get(rng.pick(texts.len())?)?.clone();
1502 let length = content.utf16_offset(content.text().len()).unwrap();
1503 let offsets: Vec<u32> = content
1504 .text()
1505 .char_indices()
1506 .map(|(i, _)| content.utf16_offset(i).unwrap())
1507 .chain([length])
1508 .collect();
1509 let a = offsets[rng.pick(offsets.len()).unwrap()];
1510 let b = offsets[rng.pick(offsets.len()).unwrap()];
1511 let range = a.min(b)..a.max(b);
1512 let holder = model::paragraph(page, paragraph).unwrap();
1513 let container = holder.parent.or_else(|| {
1514 model::lists(page)
1515 .into_iter()
1516 .find(|(_, _, list)| list.iter().any(|p| p.id == paragraph))
1517 .map(|(_, owner, _)| owner)
1518 })?;
1519 let siblings: Vec<ExGuid> = model::lists(page)
1520 .into_iter()
1521 .flat_map(|(_, _, list)| list.iter())
1522 .filter(|p| p.parent == holder.parent && p.id != paragraph)
1523 .map(|p| p.id)
1524 .collect();
1525 if rng.next().is_multiple_of(4)
1526 && let Some(op) = random_object_op(page, rng)
1527 {
1528 return Some(op);
1529 }
1530 Some(match rng.next() % 16 {
1531 0 | 1 => PageOp::Text {
1532 text,
1533 range,
1534 with: rng.text(),
1535 },
1536 2 => PageOp::Format {
1537 text,
1538 range,
1539 set: vec![TextAttribute::Bold(rng.coin())],
1540 clear: if rng.next().is_multiple_of(3) {
1541 vec![TextProperty::Italic]
1542 } else {
1543 Vec::new()
1544 },
1545 },
1546 3 => PageOp::Split {
1547 text,
1548 at: a,
1549 paragraph: id(),
1550 right: id(),
1551 lists: holder.lists.iter().map(|_| id()).collect(),
1552 },
1553 4 => {
1554 let mut new = text_paragraph(&rng.text(), content.format_at(0).unwrap().clone());
1555 new.level = holder.level;
1556 new.parent = holder.parent;
1557 PageOp::Insert {
1558 container,
1559 before: (rng.coin()).then_some(paragraph),
1560 paragraphs: vec![new],
1561 }
1562 }
1563 5 => PageOp::Delete { object: paragraph },
1564 6 => PageOp::Move {
1565 object: paragraph,
1566 parent: Some(container),
1567 before: siblings
1568 .get(rng.pick(siblings.len().max(1)).unwrap_or(0))
1569 .copied(),
1570 },
1571 7 => PageOp::Level {
1572 paragraph,
1573 level: (holder.level + 1).clamp(1, 4) - (rng.next() % 2) as u32,
1574 },
1575 8 => PageOp::Outline {
1576 object: paragraph,
1577 edit: crate::OutlineEdit::Collapsed(rng.coin()),
1578 },
1579 9 => PageOp::Paragraph {
1580 paragraph,
1581 alignment: Some((rng.next() % 3) as u8),
1582 rtl: None,
1583 space_before: Some(4.5),
1584 space_after: None,
1585 line_spacing: None,
1586 language: rng.coin().then_some(0x40c),
1587 },
1588 10 => {
1589 let right = texts
1590 .iter()
1591 .skip_while(|(p, _, _)| *p != paragraph)
1592 .nth(1)?;
1593 PageOp::Join {
1594 left: text,
1595 right: right.1,
1596 }
1597 }
1598 11 => PageOp::List {
1599 paragraph,
1600 lists: vec![(
1601 id(),
1602 Definition {
1603 kind: Kind::List {
1604 font: Some("Calibri".into()),
1605 format: Some("\u{fffd}1.".into()),
1606 restart: None,
1607 bullet: None,
1608 },
1609 format: Format::default(),
1610 },
1611 )],
1612 },
1613 12 => {
1614 let definition = id();
1615 PageOp::Tags {
1616 target: if rng.coin() { paragraph } else { text },
1617 tags: vec![crate::document::Tag {
1618 definition: Some(definition),
1619 action_type: None,
1620 shape: None,
1621 property_status: None,
1622 status: 0,
1623 created: Some(1_262_401_445),
1624 completed: None,
1625 start: None,
1626 due: None,
1627 task_id: None,
1628 extra_set: 0,
1629 }],
1630 definitions: vec![(
1631 definition,
1632 Definition {
1633 kind: Kind::TagDefinition {
1634 label: Some("Random".into()),
1635 action_type: Some(0),
1636 shape: Some(3),
1637 color: None,
1638 highlight: None,
1639 },
1640 format: Format::default(),
1641 },
1642 )],
1643 }
1644 }
1645 13 => PageOp::Link {
1646 text,
1647 range,
1648 target: rng.coin().then(|| "https://example.invalid/random".into()),
1649 },
1650 14 => PageOp::Add {
1651 object: PageObject::Outline(Outline {
1652 id: id(),
1653 title: false,
1654 min_width: None,
1655 layout: Layout {
1656 x: Some(36.0),
1657 y: Some(300.0 + (rng.next() % 100) as f32),
1658 ..Default::default()
1659 },
1660 indents: Vec::new(),
1661 paragraphs: vec![text_paragraph(&rng.text(), Format::default())],
1662 unsupported: Vec::new(),
1663 }),
1664 before: None,
1665 },
1666 _ => {
1667 let stroke = stroke(rng);
1668 let ends = [stroke.points[0], stroke.points[stroke.points.len() - 1]];
1669 PageOp::Add {
1670 object: PageObject::Ink(Ink {
1671 id: id(),
1672 layout: Layout::default(),
1673 strokes: vec![stroke],
1674 groups: Vec::new(),
1675 shape: Some(crate::page::ink::InkShape::Line(ends)),
1676 }),
1677 before: None,
1678 }
1679 }
1680 })
1681}
1682
1683/// Random edits of tables, pictures, ink, outlines and styles the page holds.
1684fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
1685 let tables: Vec<Table> = model::lists(page)
1686 .into_iter()
1687 .flat_map(|(_, _, list)| list.iter())
1688 .filter_map(|p| match &p.content {
1689 ParagraphContent::Table(table) => Some(table.clone()),
1690 _ => None,
1691 })
1692 .collect();
1693 let outlines: Vec<&Outline> = page
1694 .objects
1695 .iter()
1696 .filter_map(|o| match o {
1697 PageObject::Outline(outline) if !outline.title => Some(outline),
1698 _ => None,
1699 })
1700 .collect();
1701 let pictures: Vec<&Image> = page
1702 .objects
1703 .iter()
1704 .filter_map(|o| match o {
1705 PageObject::Image(image) => Some(image),
1706 _ => None,
1707 })
1708 .collect();
1709 let inks: Vec<&Ink> = page
1710 .objects
1711 .iter()
1712 .filter_map(|o| match o {
1713 PageObject::Ink(ink) if ink.groups.is_empty() => Some(ink),
1714 _ => None,
1715 })
1716 .collect();
1717 match rng.next() % 6 {
1718 0 | 1 => {
1719 let table = tables.get(rng.pick(tables.len())?)?;
1720 let indents = table.rows[0].cells[0].indents.clone();
1721 let cell = || TableCell {
1722 paragraphs: vec![text_paragraph("cell", Format::default())],
1723 ..new_cell(&indents)
1724 };
1725 let edit = match rng.next() % 7 {
1726 0 => TableEdit::Rows {
1727 before: table
1728 .rows
1729 .get(rng.pick(table.rows.len() + 1)?)
1730 .map(|r| r.id),
1731 rows: vec![TableRow {
1732 id: id(),
1733 cells: table.columns.iter().map(|_| cell()).collect(),
1734 }],
1735 },
1736 1 => TableEdit::Column {
1737 at: rng.pick(table.columns.len() + 1)? as u32,
1738 width: 60.0,
1739 cells: table.rows.iter().map(|_| cell()).collect(),
1740 },
1741 2 => TableEdit::DeleteRow(table.rows[rng.pick(table.rows.len())?].id),
1742 3 => TableEdit::DeleteColumn(rng.pick(table.columns.len())? as u32),
1743 4 => TableEdit::Columns(
1744 table
1745 .columns
1746 .iter()
1747 .map(|c| TableColumn {
1748 width: c.width + 9.0,
1749 locked: !c.locked,
1750 })
1751 .collect(),
1752 ),
1753 5 => TableEdit::Borders(rng.coin()),
1754 _ => {
1755 let cells: Vec<&TableCell> = table.rows.iter().flat_map(|r| &r.cells).collect();
1756 let cell = cells[rng.pick(cells.len())?];
1757 TableEdit::Cell {
1758 cell: cell.id,
1759 shading: Some(0x00ccff),
1760 indents: vec![18.0, 0.0, 27.0, 27.0],
1761 }
1762 }
1763 };
1764 Some(PageOp::Table {
1765 table: table.id,
1766 edit,
1767 })
1768 }
1769 2 => {
1770 let outline = outlines.get(rng.pick(outlines.len())?)?;
1771 Some(PageOp::Outline {
1772 object: outline.id,
1773 edit: if rng.coin() {
1774 crate::OutlineEdit::Position {
1775 x: (rng.next() % 300) as f32,
1776 y: (rng.next() % 300) as f32,
1777 }
1778 } else {
1779 crate::OutlineEdit::Width {
1780 points: 100.0 + (rng.next() % 300) as f32,
1781 user_set: rng.coin(),
1782 }
1783 },
1784 })
1785 }
1786 3 => {
1787 let picture = pictures.get(rng.pick(pictures.len())?)?;
1788 Some(PageOp::Picture {
1789 picture: picture.id,
1790 layout: Layout {
1791 x: Some((rng.next() % 300) as f32),
1792 y: Some((rng.next() % 300) as f32),
1793 max_width: Some(40.0),
1794 max_height: Some(30.0),
1795 width_set_by_user: Some(true),
1796 reserved_width: picture.layout.reserved_width,
1797 },
1798 alt: Some(rng.text()),
1799 })
1800 }
1801 4 => {
1802 let ink = inks.get(rng.pick(inks.len())?)?;
1803 let remove = if ink.strokes.len() > 1 && rng.coin() {
1804 vec![ink.strokes[rng.pick(ink.strokes.len())?].id]
1805 } else {
1806 Vec::new()
1807 };
1808 Some(PageOp::Strokes {
1809 ink: ink.id,
1810 add: vec![stroke(rng)],
1811 remove,
1812 })
1813 }
1814 _ => {
1815 let paragraphs: Vec<&PageParagraph> = model::lists(page)
1816 .into_iter()
1817 .flat_map(|(_, _, list)| list.iter())
1818 .filter(|p| p.text().is_some())
1819 .collect();
1820 let paragraph = paragraphs.get(rng.pick(paragraphs.len())?)?;
1821 Some(PageOp::Style {
1822 paragraph: paragraph.id,
1823 style: id(),
1824 definition: Definition {
1825 kind: Kind::Style {
1826 name: Some("Heading 3".into()),
1827 next: None,
1828 },
1829 format: Format {
1830 bold: Some(true),
1831 font_size: Some(12.0),
1832 color: Some(0x7d4f1e),
1833 ..Default::default()
1834 },
1835 },
1836 })
1837 }
1838 }
1839}
1840
1841/// Random edits through a section, sealed now and then: each applied edit reads back as the
1842/// model predicts, a refused one changes nothing, and reopening every sealed image gives
1843/// the same pages.
1844#[test]
1845fn random_ops_read_back_as_the_model_predicts() {
1846 let steps = 60;
1847 let (mut applied, mut refused, mut unseen) = (0, 0, 0);
1848 let mut mismatches: Vec<String> = Vec::new();
1849 for (name, source) in SOURCES {
1850 let spaces = pages(source);
1851 for seed in crate::sweep::seeds(0..2, 2) {
1852 let arena = Arena::default();
1853 let mut section = Section::open(&arena, source.to_vec()).unwrap();
1854 let mut rng = Rng(seed * 104_729 + name.len() as u64);
1855 for step in 0..steps {
1856 let space = spaces[step % spaces.len().min(3)];
1857 let before = section.page(space).unwrap();
1858 let ops: Vec<Op> = (0..1 + rng.next() % 2)
1859 .filter_map(|_| random_op(&before, &mut rng))
1860 .map(|op| Op::Page { space, op })
1861 .collect();
1862 if ops.is_empty() {
1863 continue;
1864 }
1865 let edit = Edit {
1866 at: AT + step as u64,
1867 ops,
1868 };
1869 // Text whose emptied final run keeps an insertion style the model cannot show.
1870 let hidden: Vec<ExGuid> = section
1871 .active(space)
1872 .unwrap()
1873 .view
1874 .nodes
1875 .iter()
1876 .filter(|(_, node)| {
1877 matches!(&node.kind, Kind::RichText { text, runs, .. }
1878 if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end))
1879 })
1880 .map(|(id, _)| *id)
1881 .collect();
1882 match section.apply("Author", &edit) {
1883 Ok(()) => {
1884 applied += 1;
1885 let mut predicted = before.clone();
1886 for op in &edit.ops {
1887 let Op::Page { op, .. } = op else {
1888 unreachable!()
1889 };
1890 model::apply(&mut predicted, op).unwrap();
1891 }
1892 let stored = section.page(space).unwrap();
1893 let touches_hidden = edit.ops.iter().any(|op| {
1894 matches!(op, Op::Page { op: PageOp::Text { text, .. } | PageOp::Link { text, .. } | PageOp::Split { text, .. }, .. } if hidden.contains(text))
1895 });
1896 // A restyled run may state what its old style did, which the model
1897 // cannot tell from inheriting it.
1898 let restyles = edit.ops.iter().any(|op| {
1899 matches!(op, Op::Page { op: PageOp::Style { paragraph, .. }, .. }
1900 if model::paragraph(&before, *paragraph).is_some_and(|p| p.style.is_some()))
1901 });
1902 if (touches_hidden || restyles)
1903 && normalize(&predicted) != normalize(&stored)
1904 {
1905 unseen += 1;
1906 continue;
1907 }
1908 if normalize(&predicted) != normalize(&stored) {
1909 let kinds: Vec<String> = edit
1910 .ops
1911 .iter()
1912 .map(|op| match op {
1913 Op::Page { op, .. } => format!("{op:?}")
1914 .split([' ', '{'])
1915 .next()
1916 .unwrap()
1917 .to_owned(),
1918 Op::Section(_) => "Section".into(),
1919 })
1920 .collect();
1921 if std::env::var("OP_DEBUG").is_ok() {
1922 let tree = |page: &Page| -> String {
1923 model::lists(page)
1924 .into_iter()
1925 .map(|(_, owner, list)| {
1926 format!(
1927 "{}: {}",
1928 owner.n,
1929 list.iter()
1930 .map(|p| format!(
1931 "{}<{:?}>@{}",
1932 p.id.n,
1933 p.parent.map(|p| p.n),
1934 p.level
1935 ))
1936 .collect::<Vec<_>>()
1937 .join(" ")
1938 )
1939 })
1940 .collect::<Vec<_>>()
1941 .join("\n")
1942 };
1943 println!(
1944 "ops {:?}\nbefore\n{}\nstored\n{}\npredicted\n{}",
1945 edit.ops,
1946 tree(&before),
1947 tree(&stored),
1948 tree(&predicted)
1949 );
1950 }
1951 if mismatches.len() < 6 {
1952 println!(
1953 "{name} seed {seed} step {step} {kinds:?}: {}",
1954 first_difference(&normalize(&stored), &normalize(&predicted))
1955 );
1956 }
1957 mismatches.push(kinds.join("+"));
1958 }
1959 }
1960 Err(OpError::Failed(error)) => panic!("{name} step {step}: {error:?}"),
1961 Err(_) => {
1962 refused += 1;
1963 assert!(
1964 section.page(space).unwrap() == before,
1965 "{name} step {step}: a refused edit changed the page"
1966 );
1967 }
1968 }
1969 if step % 7 == 6 || step + 1 == steps {
1970 section.seal().unwrap();
1971 let image = section.image();
1972 let reopened = Section::open(&arena, image).unwrap();
1973 for space in &spaces {
1974 assert!(
1975 reopened.page(*space).unwrap() == section.page(*space).unwrap(),
1976 "{name} step {step}: reopening changed a page"
1977 );
1978 }
1979 }
1980 }
1981 }
1982 }
1983 println!(
1984 "{applied} edits applied, {refused} refused, {unseen} restyled or typed into a hidden insertion style"
1985 );
1986 let mut kinds = BTreeMap::new();
1987 for kind in &mismatches {
1988 *kinds.entry(kind.clone()).or_insert(0) += 1;
1989 }
1990 println!("model mispredictions: {kinds:?}");
1991 assert!(mismatches.is_empty());
1992 assert!(applied > refused);
1993}
1994
1995/// Page creation, moves, indentation and removal through a section leave the page list
1996/// they describe, and the sealed image lists the pages the section does.
1997#[test]
1998fn section_ops_leave_the_page_list_they_describe() {
1999 let sources: Vec<Vec<u8>> = vec![
2000 crate::create_section("pages.one", "First", "Author").unwrap(),
2001 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one")
2002 .to_vec(),
2003 SOURCES[3].1.to_vec(),
2004 ];
2005 let mut applied = 0;
2006 for (n, source) in sources.iter().enumerate() {
2007 let arena = Arena::default();
2008 let mut section = Section::open(&arena, source.to_vec()).unwrap();
2009 let mut rng = Rng(n as u64 + 17);
2010 for step in 0..12u64 {
2011 let listed = section.pages().unwrap();
2012 let spaces: Vec<ExGuid> = listed.iter().map(|(space, ..)| *space).collect();
2013 let anchor = spaces
2014 .get(rng.pick(spaces.len()).unwrap_or(0))
2015 .copied()
2016 .filter(|_| rng.coin());
2017 let op = match step % 4 {
2018 0 => SectionOp::Create(
2019 crate::PageCreation::new(anchor, Some("Created"), "Author").unwrap(),
2020 ),
2021 1 => {
2022 let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied()
2023 else {
2024 continue;
2025 };
2026 let level = if spaces[0] == space {
2027 1
2028 } else {
2029 1 + (rng.next() % 2) as u32
2030 };
2031 SectionOp::Pages(vec![crate::PageEdit::set_level(space, level).unwrap()])
2032 }
2033 2 => {
2034 let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied()
2035 else {
2036 continue;
2037 };
2038 SectionOp::Pages(vec![crate::PageEdit::move_to(space, None, 1).unwrap()])
2039 }
2040 _ => {
2041 if spaces.len() < 2 {
2042 continue;
2043 }
2044 SectionOp::Delete(vec![spaces[rng.pick(spaces.len()).unwrap()]])
2045 }
2046 };
2047 let edit = Edit {
2048 at: AT,
2049 ops: vec![Op::Section(op.clone())],
2050 };
2051 if section.apply("Author", &edit).is_err() {
2052 assert_eq!(section.pages().unwrap(), listed, "{n} step {step} {op:?}");
2053 continue;
2054 }
2055 section.seal().unwrap();
2056 applied += 1;
2057 let after = section.pages().unwrap();
2058 let order = |list: &[(ExGuid, String, u32)], without: ExGuid| -> Vec<ExGuid> {
2059 list.iter()
2060 .map(|(space, ..)| *space)
2061 .filter(|space| *space != without)
2062 .collect()
2063 };
2064 match &op {
2065 SectionOp::Create(creation) => {
2066 let at = after
2067 .iter()
2068 .position(|(space, ..)| *space == creation.space())
2069 .unwrap();
2070 assert_eq!(after[at].1, "Created");
2071 assert_eq!(order(&after, creation.space()), spaces, "{n} step {step}");
2072 match anchor {
2073 Some(before) => assert_eq!(after[at + 1].0, before),
2074 None => assert_eq!(at + 1, after.len()),
2075 }
2076 }
2077 SectionOp::Pages(edits) => {
2078 let space = edits[0].space();
2079 let at = after
2080 .iter()
2081 .position(|(listed, ..)| *listed == space)
2082 .unwrap();
2083 assert_eq!(after[at].2, edits[0].level(), "{n} step {step} {op:?}");
2084 if edits[0].position() == crate::PagePosition::Keep {
2085 assert_eq!(order(&after, space), order(&listed, space));
2086 assert_eq!(after.iter().map(|(s, ..)| *s).collect::<Vec<_>>(), spaces);
2087 } else {
2088 assert_eq!(after.last().unwrap().0, space, "{n} step {step}");
2089 }
2090 }
2091 SectionOp::Delete(deleted) => {
2092 assert_eq!(
2093 after.iter().map(|(s, ..)| *s).collect::<Vec<_>>(),
2094 order(&listed, deleted[0])
2095 );
2096 }
2097 SectionOp::Import { .. }
2098 | SectionOp::Conflict { .. }
2099 | SectionOp::Color(_)
2100 | SectionOp::RestoreVersion { .. }
2101 | SectionOp::DeleteVersions { .. } => unreachable!(),
2102 }
2103 let image = section.image();
2104 let store = Store::parse(&image).unwrap();
2105 let index = RevisionIndex::parse(&store).unwrap();
2106 index.validate_current().unwrap();
2107 let document = Document::parse(&index).unwrap();
2108 let reread: Vec<(ExGuid, String, u32)> = document
2109 .pages()
2110 .unwrap()
2111 .into_iter()
2112 .map(|(space, page)| {
2113 let (title, level) = Page::heading(document.active(space).unwrap(), page);
2114 (space, title, level)
2115 })
2116 .collect();
2117 assert_eq!(reread, after, "{n} step {step}");
2118 for (space, ..) in &after {
2119 assert_eq!(
2120 read(&image, *space),
2121 section.page(*space).unwrap(),
2122 "{n} step {step}"
2123 );
2124 }
2125 }
2126 }
2127 assert!(applied > 12, "{applied} section edits applied");
2128}
2129
2130/// An imported page reads back as its copy under the created page's title.
2131#[test]
2132fn an_imported_page_reads_back_as_its_copy() {
2133 let target = crate::create_section("import.one", "First", "Author").unwrap();
2134 let mut imported = 0;
2135 for (name, source) in SOURCES {
2136 for space in pages(source).into_iter().take(2) {
2137 let Ok(copy) = read(source, space).copy() else {
2138 continue;
2139 };
2140 let creation = crate::PageCreation::new(None, Some(&copy.title), "Author").unwrap();
2141 let arena = Arena::default();
2142 let mut section = Section::open(&arena, target.clone()).unwrap();
2143 let import = |section: &mut Section<'_>, op: SectionOp| {
2144 section.apply(
2145 "Author",
2146 &Edit {
2147 at: AT,
2148 ops: vec![Op::Section(op)],
2149 },
2150 )
2151 };
2152 import(&mut section, SectionOp::Create(creation.clone())).unwrap();
2153 let created = section.page(creation.space()).unwrap();
2154 let mut expected = created.clone();
2155 let arena = Arena::default();
2156 let mut section = Section::open(&arena, target.clone()).unwrap();
2157 if import(
2158 &mut section,
2159 SectionOp::Import {
2160 creation: creation.clone(),
2161 page: copy.clone(),
2162 },
2163 )
2164 .is_err()
2165 {
2166 continue;
2167 }
2168 section.seal().unwrap();
2169 let image = section.image();
2170 let store = Store::parse(&image).unwrap();
2171 RevisionIndex::parse(&store)
2172 .unwrap()
2173 .validate_current()
2174 .unwrap();
2175 expected
2176 .objects
2177 .retain(|object| matches!(object, PageObject::Title(_)));
2178 expected.objects.extend(
2179 copy.objects
2180 .iter()
2181 .filter(|object| !matches!(object, PageObject::Title(_)))
2182 .cloned(),
2183 );
2184 expected.definitions.extend(copy.definitions.clone());
2185 let stored = read(&image, creation.space());
2186 // New page children stack as the writers add them.
2187 let order: Vec<ExGuid> = stored.objects.iter().map(PageObject::id).collect();
2188 expected
2189 .objects
2190 .sort_by_key(|object| order.iter().position(|id| *id == object.id()));
2191 let mut expected = as_written(&expected, &created, &stored);
2192 // An outline's height follows its content; no op carries it.
2193 for (object, stored) in expected.objects.iter_mut().zip(&stored.objects) {
2194 if let (PageObject::Outline(outline), PageObject::Outline(stored)) =
2195 (object, stored)
2196 {
2197 outline.layout.max_height = stored.layout.max_height;
2198 }
2199 }
2200 let (ours, theirs) = (normalize(&stored), normalize(&expected));
2201 assert!(
2202 ours == theirs,
2203 "{name}: {}",
2204 first_difference(&theirs, &ours)
2205 );
2206 imported += 1;
2207 }
2208 }
2209 assert!(imported > 10, "{imported} pages imported");
2210}
2211
2212/// The ops for the changed paragraphs of one outline, lowered from them alone.
2213fn lower_range(before: &Page, after: &Page) -> Option<Result<Vec<PageOp>, crate::Error>> {
2214 if before.objects.len() != after.objects.len() {
2215 return None;
2216 }
2217 let mut changed = None;
2218 for (a, b) in before.objects.iter().zip(&after.objects) {
2219 match (a, b) {
2220 (PageObject::Outline(a), PageObject::Outline(b)) if a.id == b.id => {
2221 if a.paragraphs != b.paragraphs {
2222 if changed.is_some()
2223 || (Outline {
2224 paragraphs: Vec::new(),
2225 ..a.clone()
2226 }) != (Outline {
2227 paragraphs: Vec::new(),
2228 ..b.clone()
2229 })
2230 {
2231 return None;
2232 }
2233 changed = Some((a, b));
2234 }
2235 }
2236 (a, b) if a == b => {}
2237 _ => return None,
2238 }
2239 }
2240 let (a, b) = changed?;
2241 let (a, b) = (&a.paragraphs, &b.paragraphs);
2242 let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
2243 let suffix = a[prefix..]
2244 .iter()
2245 .rev()
2246 .zip(b[prefix..].iter().rev())
2247 .take_while(|(x, y)| x == y)
2248 .count();
2249 let (mut end_a, mut end_b) = (a.len() - suffix, b.len() - suffix);
2250 // A range holds the children of what it changes.
2251 while end_a < a.len() {
2252 let ids: Vec<ExGuid> = a[prefix..end_a]
2253 .iter()
2254 .chain(&b[prefix..end_b])
2255 .map(|p| p.id)
2256 .collect();
2257 if a[end_a].parent.is_some_and(|parent| ids.contains(&parent)) {
2258 end_a += 1;
2259 end_b += 1;
2260 } else {
2261 break;
2262 }
2263 }
2264 let outline = match before
2265 .objects
2266 .iter()
2267 .find(|o| matches!(o, PageObject::Outline(o) if o.paragraphs == *a))
2268 {
2269 Some(PageObject::Outline(outline)) => outline.id,
2270 _ => return None,
2271 };
2272 let mut definitions = before.definitions.clone();
2273 definitions.extend(after.definitions.clone());
2274 Some(lower(
2275 outline,
2276 &a[prefix..end_a],
2277 &b[prefix..end_b],
2278 b.get(end_b),
2279 &definitions,
2280 ))
2281}
2282
2283/// Lowering the changed range of an outline stores what lowering the whole page does.
2284#[test]
2285fn a_range_lowers_as_its_page_does() {
2286 let families = [
2287 Family::Text,
2288 Family::Format,
2289 Family::Insert,
2290 Family::Paste,
2291 Family::Delete,
2292 Family::Move,
2293 Family::Collapse,
2294 Family::List,
2295 Family::Tag,
2296 Family::Split,
2297 Family::Join,
2298 Family::Indent,
2299 Family::Outdent,
2300 Family::Alignment,
2301 Family::Style,
2302 Family::Link,
2303 Family::NewTable,
2304 Family::ParagraphPicture,
2305 Family::Attachment,
2306 Family::Equation,
2307 ];
2308 let (mut same, mut total, mut smaller) = (0, 0, 0);
2309 for (name, source) in SOURCES {
2310 for (p, space) in pages(source).into_iter().enumerate().take(4) {
2311 for family in families {
2312 for seed in crate::sweep::seeds(0..3, 1) {
2313 let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64);
2314 let before = read(source, space);
2315 let mut after = before.clone();
2316 if !mutate(&mut after, &mut rng, family) {
2317 continue;
2318 }
2319 let Some(ranged) = lower_range(&before, &after) else {
2320 continue;
2321 };
2322 let whole = lower_page(&before, &after);
2323 let (Ok(ranged), Ok(whole)) = (ranged, whole) else {
2324 continue;
2325 };
2326 total += 1;
2327 let write = |ops: &[PageOp]| {
2328 let arena = Arena::default();
2329 let mut section = Section::open(&arena, source.to_vec()).unwrap();
2330 let edit = Edit {
2331 at: AT,
2332 ops: ops
2333 .iter()
2334 .map(|op| Op::Page {
2335 space,
2336 op: op.clone(),
2337 })
2338 .collect(),
2339 };
2340 section
2341 .apply("Author", &edit)
2342 .map(|()| normalize(&section.page(space).unwrap()))
2343 };
2344 let (ours, theirs) = (write(&ranged), write(&whole));
2345 match (ours, theirs) {
2346 (Ok(ours), Ok(theirs)) => {
2347 assert!(
2348 ours == theirs,
2349 "{name} page {p} {family:?} seed {seed}: {}",
2350 first_difference(&theirs, &ours)
2351 );
2352 same += 1;
2353 }
2354 (Err(_), Err(_)) => same += 1,
2355 (ours, theirs) => {
2356 panic!("{name} page {p} {family:?} seed {seed}: {ours:?} vs {theirs:?}")
2357 }
2358 }
2359 let length = |ops: &[PageOp]| format!("{ops:?}").len();
2360 smaller += usize::from(length(&ranged) <= length(&whole));
2361 }
2362 }
2363 }
2364 }
2365 println!("{same} of {total} ranges store what their pages do; {smaller} lowered no larger");
2366 assert_eq!(same, total);
2367 assert!(total > 100);
2368}
2369
2370/// One keystroke as ops on the 3000-paragraph probe page:
2371/// `SECTION_PROBE=path cargo test --release -p onestore --lib keystroke_ops -- --ignored --nocapture`.
2372#[test]
2373#[ignore]
2374fn keystroke_ops() {
2375 use std::time::{Duration, Instant};
2376 fn median(mut samples: Vec<Duration>) -> Duration {
2377 samples.sort();
2378 samples[samples.len() / 2]
2379 }
2380 let path = std::env::var("SECTION_PROBE").unwrap_or("/tmp/probe3000.one".into());
2381 let image = std::fs::read(&path).unwrap();
2382 let space = pages(&image)
2383 .into_iter()
2384 .max_by_key(|space| {
2385 read(&image, *space).objects.len() + format!("{:?}", read(&image, *space)).len()
2386 })
2387 .unwrap();
2388 let page = read(&image, space);
2389 let outline = page
2390 .objects
2391 .iter()
2392 .filter_map(|o| match o {
2393 PageObject::Outline(outline) => Some(outline),
2394 _ => None,
2395 })
2396 .max_by_key(|o| o.paragraphs.len())
2397 .unwrap();
2398 let middle = outline.paragraphs.len() / 2;
2399 let paragraph = &outline.paragraphs[middle];
2400 let text = paragraph.text().unwrap().id;
2401 println!(
2402 "{path}: {} bytes, {} paragraphs in the outline",
2403 image.len(),
2404 outline.paragraphs.len()
2405 );
2406
2407 // Emitting: a keystroke is one op; a generic range replacement lowers its paragraphs.
2408 let mut edited = paragraph.clone();
2409 let format = edited.text().unwrap().text.format_at(0).unwrap().clone();
2410 edited
2411 .text_mut()
2412 .unwrap()
2413 .text
2414 .apply(crate::page::text::Edit {
2415 range: 0..0,
2416 replacement: Paragraph::new("x".into(), format),
2417 })
2418 .unwrap();
2419 let mut samples = Vec::new();
2420 for _ in 0..200 {
2421 let start = Instant::now();
2422 let ops = lower(
2423 outline.id,
2424 std::slice::from_ref(paragraph),
2425 std::slice::from_ref(&edited),
2426 outline.paragraphs.get(middle + 1),
2427 &page.definitions,
2428 )
2429 .unwrap();
2430 samples.push(start.elapsed());
2431 assert_eq!(ops.len(), 1);
2432 }
2433 println!("lower one paragraph: {:?}", median(samples));
2434
2435 let arena = Arena::default();
2436 let start = Instant::now();
2437 let mut section = Section::open(&arena, image.clone()).unwrap();
2438 println!("Section::open: {:?}", start.elapsed());
2439 let start = Instant::now();
2440 section.active(space).unwrap();
2441 println!("first edit opens the page: {:?}", start.elapsed());
2442 // Five keystrokes a second, each sealed: the outline's modification time ticks once a
2443 // second, which re-declares its child list.
2444 let (mut applies, mut seals, mut sizes) = (Vec::new(), Vec::new(), Vec::new());
2445 for keystroke in 0..300u32 {
2446 let edit = Edit {
2447 at: AT + u64::from(keystroke) * 2_000_000,
2448 ops: vec![Op::Page {
2449 space,
2450 op: PageOp::Text {
2451 text,
2452 range: keystroke..keystroke,
2453 with: "x".into(),
2454 },
2455 }],
2456 };
2457 let start = Instant::now();
2458 section.apply("Probe", &edit).unwrap();
2459 applies.push(start.elapsed());
2460 let start = Instant::now();
2461 let transaction = section.seal().unwrap().unwrap();
2462 seals.push(start.elapsed());
2463 sizes.push((
2464 transaction.append.len(),
2465 transaction
2466 .patches
2467 .iter()
2468 .map(|(_, bytes)| bytes.len())
2469 .sum::<usize>(),
2470 ));
2471 }
2472 sizes.sort();
2473 println!(
2474 "Section::apply(Text) {:?}, seal {:?}; appended {} bytes (max {}), patched {} bytes (median of 300)",
2475 median(applies),
2476 median(seals),
2477 sizes[sizes.len() / 2].0,
2478 sizes.last().unwrap().0,
2479 sizes[sizes.len() / 2].1,
2480 );
2481 let start = Instant::now();
2482 let reopened = Section::open(&arena, section.image()).unwrap();
2483 println!("reopen after 300 seals: {:?}", start.elapsed());
2484 assert_eq!(reopened.page(space).unwrap(), section.page(space).unwrap());
2485}
2486
2487/// Writes a notebook for the native cold-open gate of the op path to `OP_GATE_EXPORT`:
2488/// corpus pages imported as ops, then edited by lowered page edits of every family, new
2489/// pages, an outline move on a page without a margin origin, page moves and a removal.
2490#[test]
2491#[ignore]
2492fn op_gate_candidate() {
2493 let directory = std::path::PathBuf::from(std::env::var("OP_GATE_EXPORT").unwrap());
2494 std::fs::create_dir_all(&directory).unwrap();
2495 let source = crate::create_section("ops.one", "Ops gate", "Author").unwrap();
2496 let arena = Arena::default();
2497 let mut section = Section::open(&arena, source).unwrap();
2498 let at = std::cell::Cell::new(AT);
2499 let next = || {
2500 at.set(at.get() + 20_000_000);
2501 at.get()
2502 };
2503 let apply = |section: &mut Section<'_>, op: Op| {
2504 let result = section.apply(
2505 "Gate author",
2506 &Edit {
2507 at: next(),
2508 ops: vec![op],
2509 },
2510 );
2511 section.seal().unwrap();
2512 result
2513 };
2514 let mut imported = Vec::new();
2515 for (name, bytes) in SOURCES.iter().filter(|(name, _)| *name != "enter-probe") {
2516 let Some(space) = pages(bytes).first().copied() else {
2517 continue;
2518 };
2519 let page = read(bytes, space);
2520 if page
2521 .objects
2522 .iter()
2523 .any(|o| matches!(o, PageObject::Unsupported(_)))
2524 {
2525 continue;
2526 }
2527 let Ok(copy) = page.copy() else { continue };
2528 let creation =
2529 crate::PageCreation::new(None, Some(&format!("Imported {name}")), "Gate author")
2530 .unwrap();
2531 if apply(
2532 &mut section,
2533 Op::Section(SectionOp::Import {
2534 creation: creation.clone(),
2535 page: copy,
2536 }),
2537 )
2538 .is_ok()
2539 {
2540 imported.push(creation.space());
2541 }
2542 }
2543 let mut edits = 0;
2544 for (n, space) in imported.iter().enumerate() {
2545 for (k, family) in FAMILIES.iter().enumerate() {
2546 if (k + n) % 4 != 0 {
2547 continue;
2548 }
2549 let before = section.page(*space).unwrap();
2550 let mut after = before.clone();
2551 let mut rng = Rng((n * 100 + k) as u64);
2552 if !mutate(&mut after, &mut rng, *family) {
2553 continue;
2554 }
2555 // Edits inside a field code leave a field OneNote and this reader resolve apart,
2556 // whichever writer stores them.
2557 let fields = |page: &Page| -> Vec<(ExGuid, String)> {
2558 model::lists(page)
2559 .into_iter()
2560 .flat_map(|(_, _, list)| list.iter())
2561 .filter_map(|p| p.text())
2562 .filter(|t| t.text.text().contains('\u{fddf}'))
2563 .map(|t| (t.id, t.text.text().to_owned()))
2564 .collect()
2565 };
2566 let edited = fields(&before);
2567 if fields(&after)
2568 .iter()
2569 .any(|(id, text)| edited.iter().any(|(e, old)| e == id && old != text))
2570 {
2571 continue;
2572 }
2573 if section
2574 .apply_page("Gate author", next(), *space, &after)
2575 .is_ok()
2576 {
2577 section.seal().unwrap();
2578 edits += 1;
2579 }
2580 }
2581 }
2582 // A created page: margins, an outline, and its first move adding the margin origin.
2583 let creation = crate::PageCreation::new(None, Some("Created by ops"), "Gate author").unwrap();
2584 apply(
2585 &mut section,
2586 Op::Section(SectionOp::Create(creation.clone())),
2587 )
2588 .unwrap();
2589 let outline = Outline {
2590 id: id(),
2591 title: false,
2592 min_width: None,
2593 layout: Layout {
2594 x: Some(36.0),
2595 y: Some(86.4),
2596 ..Default::default()
2597 },
2598 indents: Vec::new(),
2599 paragraphs: vec![text_paragraph("Typed through ops", Format::default())],
2600 unsupported: Vec::new(),
2601 };
2602 let outline_id = outline.id;
2603 let text = outline.paragraphs[0].text().unwrap().id;
2604 let space = creation.space();
2605 let page = |op| Op::Page { space, op };
2606 apply(
2607 &mut section,
2608 page(PageOp::Add {
2609 object: PageObject::Outline(outline),
2610 before: None,
2611 }),
2612 )
2613 .unwrap();
2614 apply(
2615 &mut section,
2616 page(PageOp::Outline {
2617 object: outline_id,
2618 edit: crate::OutlineEdit::Position { x: 72.0, y: 108.0 },
2619 }),
2620 )
2621 .unwrap();
2622 apply(
2623 &mut section,
2624 page(PageOp::Text {
2625 text,
2626 range: 5..5,
2627 with: " and sealed".into(),
2628 }),
2629 )
2630 .unwrap();
2631 apply(
2632 &mut section,
2633 page(PageOp::Link {
2634 text,
2635 range: 0..5,
2636 target: Some("https://example.invalid/ops".into()),
2637 }),
2638 )
2639 .unwrap();
2640 let spaces: Vec<ExGuid> = section
2641 .pages()
2642 .unwrap()
2643 .into_iter()
2644 .map(|(s, _, _)| s)
2645 .collect();
2646 apply(
2647 &mut section,
2648 Op::Section(SectionOp::Pages(vec![
2649 crate::PageEdit::move_to(space, Some(spaces[1]), 1).unwrap(),
2650 ])),
2651 )
2652 .unwrap();
2653 apply(
2654 &mut section,
2655 Op::Section(SectionOp::Pages(vec![
2656 crate::PageEdit::set_level(spaces[2], 2).unwrap(),
2657 ])),
2658 )
2659 .unwrap();
2660 apply(
2661 &mut section,
2662 Op::Section(SectionOp::Delete(vec![*spaces.last().unwrap()])),
2663 )
2664 .unwrap();
2665 let image = section.image();
2666 let store = Store::parse(&image).unwrap();
2667 RevisionIndex::parse(&store)
2668 .unwrap()
2669 .validate_current()
2670 .unwrap();
2671 let count = section.pages().unwrap().len();
2672 std::fs::write(directory.join("ops.one"), &image).unwrap();
2673 std::fs::write(
2674 directory.join("Open Notebook.onetoc2"),
2675 crate::create_table_of_contents(
2676 "Open Notebook.onetoc2",
2677 &[("ops.one", store.header.file_id)],
2678 )
2679 .unwrap(),
2680 )
2681 .unwrap();
2682 std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap();
2683 println!(
2684 "{} imported pages, {edits} lowered edits, {count} pages",
2685 imported.len()
2686 );
2687}
2688
2689/// OneNote 2010's own date change (`corpus/page-date/native`, COM `UpdatePageContent`
2690/// with a new `dateTime`): the page's second page, "Changed date", took it; "Kept date"
2691/// did not.
2692const DATED: &[u8] = include_bytes!("../../../../corpus/page-date/native/notebook/pictures.one");
2693
2694/// The objects of `space`'s active revision that differ from its previous one, by jcid.
2695fn changed_jcids(image: &[u8], space: ExGuid) -> Vec<u32> {
2696 let store = Store::parse(image).unwrap();
2697 let index = RevisionIndex::parse(&store).unwrap();
2698 let active = index.active(space).unwrap();
2699 let previous = index.spaces[&space].revisions[&active].dependency.unwrap();
2700 let (old, new) = (
2701 index.resolve(space, previous).unwrap(),
2702 index.resolve(space, active).unwrap(),
2703 );
2704 let mut jcids: Vec<u32> = new
2705 .objects
2706 .iter()
2707 .filter(|(id, object)| {
2708 old.objects
2709 .get(id)
2710 .is_none_or(|old| format!("{:?}", old.data) != format!("{:?}", object.data))
2711 })
2712 .map(|(_, object)| object.jcid)
2713 .collect();
2714 jcids.sort();
2715 jcids
2716}
2717
2718/// The title's date and time fields of `page`, by text object.
2719fn date_fields(page: &Page) -> Vec<(ExGuid, String)> {
2720 model::date_fields(page)
2721 .into_iter()
2722 .map(|text| (text.id, text.text.text().to_owned()))
2723 .collect()
2724}
2725
2726/// FILETIME of 2025-07-04 16:45 UTC, which a Pacific clock shows as 9:45 AM.
2727const DATED_AT: u64 = 133_961_211_000_000_000;
2728
2729/// The `Date` op writes what OneNote writes for a new page date: the metadata's creation
2730/// time, the date and time field text, and the modification times above them.
2731#[test]
2732fn a_page_date_stores_what_onenote_stores() {
2733 let spaces = pages(DATED);
2734 let title = |space: &ExGuid| read(DATED, *space).title;
2735 let kept = *spaces
2736 .iter()
2737 .find(|space| title(space) == "Kept date")
2738 .unwrap();
2739 let changed = *spaces
2740 .iter()
2741 .find(|space| title(space) == "Changed date")
2742 .unwrap();
2743 let native = changed_jcids(DATED, changed);
2744 assert_eq!(
2745 native,
2746 [0x20030, 0x6000c, 0x6000d, 0x6000d, 0x6000e, 0x6000e]
2747 );
2748
2749 let arena = Arena::default();
2750 let mut section = Section::open(&arena, DATED.to_vec()).unwrap();
2751 let before = section.page(kept).unwrap();
2752 // The date field precedes the time field.
2753 let fields: Vec<(ExGuid, String)> = date_fields(&before)
2754 .into_iter()
2755 .zip(["Friday, July 04, 2025", "9:45 AM"])
2756 .map(|((id, _), shown)| (id, shown.to_owned()))
2757 .collect();
2758 assert_eq!(fields.len(), 2);
2759 let op = PageOp::Date {
2760 created: DATED_AT,
2761 fields: fields.clone(),
2762 };
2763 let mut predicted = before.clone();
2764 model::apply(&mut predicted, &op).unwrap();
2765 section
2766 .apply(
2767 "Author",
2768 &Edit {
2769 at: AT,
2770 ops: vec![Op::Page { space: kept, op }],
2771 },
2772 )
2773 .unwrap();
2774 section.seal().unwrap();
2775 let image = section.image();
2776 let stored = read(&image, kept);
2777 assert_eq!(stored.created, Some(DATED_AT));
2778 assert_eq!(date_fields(&stored), fields);
2779 assert_eq!(stored, predicted);
2780 assert_eq!(changed_jcids(&image, kept), native);
2781 let store = Store::parse(&image).unwrap();
2782 RevisionIndex::parse(&store)
2783 .unwrap()
2784 .validate_current()
2785 .unwrap();
2786
2787 // The whole-page lowering reaches the same page through the same op.
2788 let mut after = before.clone();
2789 after.created = Some(DATED_AT);
2790 model::apply(
2791 &mut after,
2792 &PageOp::Date {
2793 created: DATED_AT,
2794 fields,
2795 },
2796 )
2797 .unwrap();
2798 assert!(matches!(
2799 &lower_page(&before, &after).unwrap()[..],
2800 [PageOp::Date { .. }]
2801 ));
2802
2803 if let Some(directory) = std::env::var_os("ONESTORE_PAGE_DATE_EXPORT") {
2804 let directory = std::path::Path::new(&directory);
2805 std::fs::create_dir_all(directory).unwrap();
2806 std::fs::write(directory.join("pictures.one"), &image).unwrap();
2807 std::fs::copy(
2808 concat!(
2809 env!("CARGO_MANIFEST_DIR"),
2810 "/../../corpus/page-date/native/notebook/Open Notebook.onetoc2"
2811 ),
2812 directory.join("Open Notebook.onetoc2"),
2813 )
2814 .unwrap();
2815 }
2816}
2817
2818/// OneNote 2010's View, Rule Lines (`corpus/rule-lines/native`): each page is titled for
2819/// what its menu gave it, and "Narrow" was ruled, then set back to None.
2820const RULED: &[u8] = include_bytes!("../../../../corpus/rule-lines/native/notebook/Rules.one");
2821
2822/// The rule-line properties on `space`'s page node, in stored order.
2823fn rule_properties(image: &[u8], space: ExGuid) -> Vec<(u32, Vec<u8>)> {
2824 let store = Store::parse(image).unwrap();
2825 let index = RevisionIndex::parse(&store).unwrap();
2826 let revision = index.resolve(space, index.active(space).unwrap()).unwrap();
2827 let document = Document::parse(&index).unwrap();
2828 let [node] = crate::active::manifest_pages(document.active(space).unwrap())[..] else {
2829 panic!("one page node")
2830 };
2831 let crate::ObjectData::Properties(bytes) = revision.objects[&node].data else {
2832 panic!("a page node's properties")
2833 };
2834 crate::PropertySets::parse(bytes).unwrap().sets[0]
2835 .iter()
2836 .filter(|property| crate::page::RULE_LINES.contains(&property.id))
2837 .map(|property| match property.value {
2838 crate::Value::Bytes(bytes) => (property.id, bytes.to_vec()),
2839 _ => panic!("rule lines are scalars"),
2840 })
2841 .collect()
2842}
2843
2844/// Rule lines read as OneNote's page XML reports them, and the `RuleLines` op stores the
2845/// properties OneNote stores, in its order, or removes them for None.
2846#[test]
2847fn rule_lines_store_what_onenote_stores() {
2848 let apply = |section: &mut Section, op| {
2849 section.apply(
2850 "Author",
2851 &Edit {
2852 at: AT,
2853 ops: vec![op],
2854 },
2855 )
2856 };
2857 let titled = |image: &[u8], title: &str| {
2858 *pages(image)
2859 .iter()
2860 .find(|space| read(image, **space).title == title)
2861 .unwrap()
2862 };
2863 let lines = |title| read(RULED, titled(RULED, title)).rule_lines;
2864 let blue = 0xfdebca;
2865 assert_eq!(lines("None"), None);
2866 assert_eq!(lines("Narrow"), None);
2867 // The page XML reports these spacings in points.
2868 for (title, spacing, vertical) in [
2869 ("College", 23.76, None),
2870 ("Standard", 33.12, None),
2871 ("Wide", 46.8, None),
2872 ("SmallGrid", 12.0, Some(blue)),
2873 ("MediumGrid", 28.3464, Some(0xffc9e0)),
2874 ("LargeGrid", 42.5196, Some(blue)),
2875 ("VeryLargeGrid", 56.6928, Some(blue)),
2876 ] {
2877 let lines = lines(title).unwrap();
2878 assert!((lines.spacing * 36.0 - spacing).abs() < 1e-3, "{title}");
2879 assert_eq!(lines.color, vertical.unwrap_or(blue), "{title}");
2880 assert_eq!(
2881 lines.vertical,
2882 match vertical {
2883 Some(color) => VerticalRule::Grid {
2884 spacing: lines.spacing,
2885 color,
2886 },
2887 None => VerticalRule::Margin(0x5050ff),
2888 },
2889 "{title}"
2890 );
2891 }
2892 assert_eq!(lines("ColorRed").unwrap().color, 0xd6d4ff);
2893 // "Hidden" is Standard in the menu's "<none>" colour, white, on a Teal page.
2894 assert_eq!(lines("Hidden").unwrap().color, 0xffffff);
2895
2896 // Each page's lines given to the unruled page store what OneNote stored for them.
2897 let arena = Arena::default();
2898 let bare = titled(RULED, "None");
2899 for title in [
2900 "College",
2901 "Standard",
2902 "Wide",
2903 "SmallGrid",
2904 "MediumGrid",
2905 "LargeGrid",
2906 "VeryLargeGrid",
2907 "ColorRed",
2908 "Hidden",
2909 ] {
2910 let mut section = Section::open(&arena, RULED.to_vec()).unwrap();
2911 let op = PageOp::RuleLines(lines(title));
2912 let mut predicted = section.page(bare).unwrap();
2913 model::apply(&mut predicted, &op).unwrap();
2914 apply(&mut section, Op::Page { space: bare, op }).unwrap();
2915 section.seal().unwrap();
2916 let image = section.image();
2917 assert_eq!(read(&image, bare), predicted, "{title}");
2918 assert_eq!(
2919 rule_properties(&image, bare),
2920 rule_properties(RULED, titled(RULED, title)),
2921 "{title}"
2922 );
2923 }
2924
2925 // The candidate: rules added, changed between ruled and grid, and removed on OneNote's
2926 // pages, and a created page ruled.
2927 let mut section = Section::open(&arena, RULED.to_vec()).unwrap();
2928 let creation = crate::PageCreation::new(None, Some("Created"), "Author").unwrap();
2929 apply(
2930 &mut section,
2931 Op::Section(SectionOp::Create(creation.clone())),
2932 )
2933 .unwrap();
2934 let wide = lines("Wide");
2935 let red_narrow = crate::page::RuleLines {
2936 spacing: f32::from_bits(0x3ebe_f8d2),
2937 color: 0xd6d4ff,
2938 vertical: VerticalRule::Margin(0x5050ff),
2939 };
2940 let edits = [
2941 (bare, wide),
2942 (titled(RULED, "College"), None),
2943 (titled(RULED, "Standard"), lines("SmallGrid")),
2944 (titled(RULED, "MediumGrid"), Some(red_narrow)),
2945 (creation.space(), lines("VeryLargeGrid")),
2946 ];
2947 for (space, lines) in edits {
2948 apply(
2949 &mut section,
2950 Op::Page {
2951 space,
2952 op: PageOp::RuleLines(lines),
2953 },
2954 )
2955 .unwrap();
2956 section.seal().unwrap();
2957 }
2958 let image = section.image();
2959 RevisionIndex::parse(&Store::parse(&image).unwrap())
2960 .unwrap()
2961 .validate_current()
2962 .unwrap();
2963 for (space, lines) in edits {
2964 assert_eq!(read(&image, space).rule_lines, lines);
2965 }
2966 assert!(rule_properties(&image, titled(&image, "College")).is_empty());
2967
2968 // The whole-page lowering reaches the same pages through the same op.
2969 let before = read(RULED, bare);
2970 let mut after = before.clone();
2971 after.rule_lines = wide;
2972 assert_eq!(
2973 lower_page(&before, &after).unwrap(),
2974 [PageOp::RuleLines(wide)]
2975 );
2976
2977 if let Some(directory) = std::env::var_os("ONESTORE_RULE_LINES_EXPORT") {
2978 let directory = std::path::Path::new(&directory);
2979 std::fs::create_dir_all(directory).unwrap();
2980 std::fs::write(directory.join("Rules.one"), &image).unwrap();
2981 std::fs::copy(
2982 concat!(
2983 env!("CARGO_MANIFEST_DIR"),
2984 "/../../corpus/rule-lines/native/notebook/Open Notebook.onetoc2"
2985 ),
2986 directory.join("Open Notebook.onetoc2"),
2987 )
2988 .unwrap();
2989 }
2990}
2991
2992/// Text added without a language stores the writer's, and lowering it back must not write
2993/// LCID 0 on the text object, which runs later written without a language would read.
2994#[test]
2995fn an_unset_language_is_not_paragraph_formatting() {
2996 let language = |language| Format {
2997 language,
2998 ..Default::default()
2999 };
3000 let stored = Paragraph::new(String::new(), language(Some(0x409)));
3001 let fields = |target| lower::paragraph_fields(&stored, &target).unwrap().language;
3002 assert_eq!(fields(Paragraph::new("Fresh".into(), language(None))), None);
3003 assert_eq!(
3004 fields(Paragraph::new("Frais".into(), language(Some(0x40c)))),
3005 Some(0x40c)
3006 );
3007}
3008
3009/// A paragraph given a language, then an equation whose runs name none: the model predicts
3010/// what the section stores.
3011#[test]
3012fn runs_written_after_a_paragraph_language_read_as_predicted() {
3013 let (_, source) = SOURCES[1];
3014 let space = pages(source)[0];
3015 let arena = Arena::default();
3016 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3017 let before = section.page(space).unwrap();
3018 let paragraph = model::lists(&before)
3019 .into_iter()
3020 .flat_map(|(_, _, list)| list.iter())
3021 .find(|paragraph| {
3022 paragraph
3023 .text()
3024 .is_some_and(|text| !text.text.text().is_empty())
3025 })
3026 .unwrap()
3027 .clone();
3028 let text = paragraph.text().unwrap().id;
3029 let math = Format {
3030 math: Some(true),
3031 ..Default::default()
3032 };
3033 let ops = [
3034 PageOp::Paragraph {
3035 paragraph: paragraph.id,
3036 alignment: None,
3037 rtl: None,
3038 space_before: None,
3039 space_after: None,
3040 line_spacing: None,
3041 language: Some(0x40c),
3042 },
3043 PageOp::Equation {
3044 text,
3045 math: Paragraph::new("x".into(), math),
3046 },
3047 ];
3048 let mut predicted = before;
3049 for op in ops {
3050 model::apply(&mut predicted, &op).unwrap();
3051 section
3052 .apply(
3053 "Author",
3054 &Edit {
3055 at: AT,
3056 ops: vec![Op::Page { space, op }],
3057 },
3058 )
3059 .unwrap();
3060 let stored = section.page(space).unwrap();
3061 assert_eq!(
3062 normalize(&predicted),
3063 normalize(&stored),
3064 "{}",
3065 first_difference(&normalize(&stored), &normalize(&predicted))
3066 );
3067 }
3068}
3069
3070/// Undoing a deletion adds the object back whole: an outline whose first paragraph OneNote
3071/// tabbed into a group, and a TIFF picture OneNote imported, store as they were.
3072#[test]
3073fn deleted_objects_added_back_store_as_they_were() {
3074 let tree = SOURCES[2].1;
3075 let features = SOURCES[3].1;
3076 for (source, title) in [
3077 (tree, "Outdent first group"),
3078 (tree, "Delete unindented sibling after group"),
3079 (features, "Image tiff"),
3080 ] {
3081 let space = *pages(source)
3082 .iter()
3083 .find(|space| read(source, **space).title == title)
3084 .unwrap();
3085 let arena = Arena::default();
3086 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3087 let before = section.page(space).unwrap();
3088 let shape = |section: &mut Section, id: ExGuid| {
3089 let view = &section.active(space).unwrap().view;
3090 let mut shape = Vec::new();
3091 let mut pending = vec![(id, 0)];
3092 while let Some((id, depth)) = pending.pop() {
3093 let node = &view.nodes[&id];
3094 shape.push((
3095 depth,
3096 node.child_level,
3097 matches!(node.kind, Kind::OutlineGroup),
3098 ));
3099 pending.extend(node.children.iter().rev().map(|child| (*child, depth + 1)));
3100 }
3101 shape
3102 };
3103 let at = before
3104 .objects
3105 .iter()
3106 .position(|object| matches!(object, PageObject::Outline(_) | PageObject::Image(_)))
3107 .unwrap();
3108 let object = before.objects[at].id();
3109 let stored = shape(&mut section, object);
3110 let edit = |ops| Edit { at: AT, ops };
3111 section
3112 .apply(
3113 "Author",
3114 &edit(vec![Op::Page {
3115 space,
3116 op: PageOp::Delete { object },
3117 }]),
3118 )
3119 .unwrap();
3120 let gone = section.page(space).unwrap();
3121 let ops = lower_page(&gone, &before)
3122 .unwrap()
3123 .into_iter()
3124 .map(|op| Op::Page { space, op })
3125 .collect();
3126 section
3127 .apply("Author", &edit(ops))
3128 .unwrap_or_else(|error| panic!("{title}: {error:?}"));
3129 let after = section.page(space).unwrap();
3130 assert_eq!(normalize(&after), normalize(&before), "{title}");
3131 assert_eq!(shape(&mut section, object), stored, "{title}");
3132 }
3133}
3134
3135/// A style definition named `name` in random formatting, as a theme gives one.
3136fn random_style(name: &str, rng: &mut Rng) -> Definition {
3137 let fonts = ["Georgia", "Arial", "Calibri", "Courier New"];
3138 Definition {
3139 kind: Kind::Style {
3140 name: Some(name.into()),
3141 next: rng.coin().then(|| "p".into()),
3142 },
3143 format: Format {
3144 bold: Some(rng.coin()),
3145 italic: Some(rng.coin()),
3146 underline: Some(false),
3147 strike: Some(false),
3148 superscript: Some(false),
3149 subscript: Some(false),
3150 font: Some(fonts[rng.next() as usize % fonts.len()].into()),
3151 font_size: Some(8.0 + (rng.next() % 30) as f32 / 2.0),
3152 color: Some(if rng.coin() {
3153 0xff00_0000
3154 } else {
3155 rng.next() as u32 & 0xff_ffff
3156 }),
3157 highlight: Some(0xff00_0000),
3158 space_before: Some((rng.next() % 4) as f32 * 3.0),
3159 space_after: Some((rng.next() % 3) as f32 * 2.0),
3160 line_spacing: Some(0.0),
3161 ..Default::default()
3162 },
3163 }
3164}
3165
3166/// Restyling a page's paragraph styles, after runs were given values their style already
3167/// gives, reads back as the model predicts; reopened, the section holds the same pages, and
3168/// a second restyle to the same sheet has nothing left to do.
3169#[test]
3170fn restyles_read_back_as_the_model_predicts() {
3171 let sources = SOURCES.iter().copied().chain([(
3172 "styles",
3173 &include_bytes!("../../../../corpus/styles/onenote/Styles.one")[..],
3174 )]);
3175 let mut restyled = 0;
3176 for (name, source) in sources {
3177 for space in pages(source) {
3178 let arena = Arena::default();
3179 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3180 let mut rng = Rng(space.n as u64 * 7919 + name.len() as u64);
3181 let before = section.page(space).unwrap();
3182 // Runs stating their style's font and size, which the restyle clears.
3183 let mut ops = Vec::new();
3184 for (_, _, list) in model::lists(&before) {
3185 for paragraph in list {
3186 let (Some(style), Some(text)) = (paragraph.style, paragraph.text()) else {
3187 continue;
3188 };
3189 let format = &before.definitions[&style].format;
3190 let length = text.text.utf16_offset(text.text.text().len()).unwrap();
3191 if length == 0 || text.date_field.is_some() || !rng.coin() {
3192 continue;
3193 }
3194 let set: Vec<TextAttribute> = [
3195 format.font.clone().map(TextAttribute::Font),
3196 format.font_size.map(TextAttribute::FontSize),
3197 ]
3198 .into_iter()
3199 .flatten()
3200 .collect();
3201 if !set.is_empty() && !crate::page::Math::is_equation(&text.text) {
3202 ops.push(PageOp::Format {
3203 text: text.id,
3204 range: 0..length,
3205 set,
3206 clear: Vec::new(),
3207 });
3208 }
3209 }
3210 }
3211 let mut stated = before.clone();
3212 for op in ops.clone() {
3213 if section
3214 .apply(
3215 "Author",
3216 &Edit {
3217 at: AT,
3218 ops: vec![Op::Page {
3219 space,
3220 op: op.clone(),
3221 }],
3222 },
3223 )
3224 .is_ok()
3225 {
3226 model::apply(&mut stated, &op).unwrap();
3227 }
3228 }
3229 let names: BTreeSet<String> = stated
3230 .definitions
3231 .values()
3232 .filter_map(|definition| match &definition.kind {
3233 Kind::Style { name, .. } => name.clone(),
3234 _ => None,
3235 })
3236 .collect();
3237 let sheet: BTreeMap<String, Definition> = names
3238 .iter()
3239 .map(|style| (style.clone(), random_style(style, &mut rng)))
3240 .collect();
3241 let ops = restyle(&stated, &sheet).unwrap();
3242 if ops.is_empty() {
3243 continue;
3244 }
3245 restyled += ops.len();
3246 let edit = Edit {
3247 at: AT + 1,
3248 ops: ops
3249 .iter()
3250 .map(|op| Op::Page {
3251 space,
3252 op: op.clone(),
3253 })
3254 .collect(),
3255 };
3256 section
3257 .apply("Author", &edit)
3258 .unwrap_or_else(|error| panic!("{name} {space:?}: {error:?}"));
3259 let mut predicted = section.page(space).unwrap();
3260 let stored = predicted.clone();
3261 predicted.clone_from(&stated);
3262 for op in &ops {
3263 model::apply(&mut predicted, op).unwrap();
3264 }
3265 assert!(
3266 normalize(&predicted) == normalize(&stored),
3267 "{name} {space:?}: {}",
3268 first_difference(&normalize(&stored), &normalize(&predicted))
3269 );
3270 assert!(
3271 restyle(&stored, &sheet).unwrap().is_empty(),
3272 "{name}: restyled twice"
3273 );
3274 section.seal().unwrap();
3275 let reopened = Section::open(&arena, section.image()).unwrap();
3276 assert!(reopened.page(space).unwrap() == stored, "{name}: reopened");
3277 }
3278 }
3279 assert!(restyled > 20, "{restyled} styles restyled");
3280}
3281
3282/// A restyle keeps the style's name and needs the page to hold the style.
3283#[test]
3284fn restyle_refuses_renames_and_missing_styles() {
3285 let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one");
3286 let arena = Arena::default();
3287 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3288 let space = pages(source)[0];
3289 let page = section.page(space).unwrap();
3290 let (&h1, stored) = page
3291 .definitions
3292 .iter()
3293 .find(|(_, d)| matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "h1"))
3294 .unwrap();
3295 assert_eq!(
3296 stored.kind,
3297 Kind::Style {
3298 name: Some("h1".into()),
3299 next: Some("p".into()),
3300 }
3301 );
3302 let mut renamed = stored.clone();
3303 renamed.kind = Kind::Style {
3304 name: Some("h2".into()),
3305 next: None,
3306 };
3307 for (style, definition) in [(h1, renamed), (id(), stored.clone())] {
3308 let edit = Edit {
3309 at: AT,
3310 ops: vec![Op::Page {
3311 space,
3312 op: PageOp::Restyle {
3313 style,
3314 into: id(),
3315 definition,
3316 },
3317 }],
3318 };
3319 assert!(section.apply("Author", &edit).is_err());
3320 assert!(section.page(space).unwrap() == page);
3321 }
3322}
3323
3324/// An equation's paragraph restyled in italics, then lowered back to the page before it, as
3325/// undo does: the math runs a style left italic go back whole, since they refuse formatting.
3326#[test]
3327fn a_restyled_equation_lowers_back() {
3328 let (_, source) = SOURCES.iter().find(|(name, _)| *name == "math").unwrap();
3329 let space = pages(source)[0];
3330 let arena = Arena::default();
3331 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3332 let before = section.page(space).unwrap();
3333 let paragraph = model::lists(&before)
3334 .into_iter()
3335 .flat_map(|(_, _, list)| list.iter())
3336 .find(|paragraph| {
3337 paragraph
3338 .text()
3339 .is_some_and(|text| crate::page::Math::is_equation(&text.text))
3340 })
3341 .unwrap()
3342 .id;
3343 let style = PageOp::Style {
3344 paragraph,
3345 style: id(),
3346 definition: Definition {
3347 kind: Kind::Style {
3348 name: Some("Quote".into()),
3349 next: None,
3350 },
3351 format: Format {
3352 italic: Some(true),
3353 ..Default::default()
3354 },
3355 },
3356 };
3357 let edit = |ops: Vec<PageOp>| Edit {
3358 at: AT,
3359 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
3360 };
3361 section.apply("Author", &edit(vec![style])).unwrap();
3362 let styled = section.page(space).unwrap();
3363 section
3364 .apply("Author", &edit(lower_page(&styled, &before).unwrap()))
3365 .unwrap();
3366 let after = section.page(space).unwrap();
3367 assert_eq!(
3368 normalize(&after),
3369 normalize(&before),
3370 "{}",
3371 first_difference(&normalize(&after), &normalize(&before))
3372 );
3373}