1//! Equations as OneNote 2010's equation editor edits them (`corpus/math-edit/native-linear`):
2//! Alt+= starts an equation at the caret or makes one of the selected text, text typed into
3//! an equation is its linear format until a space builds it up, and Linear and Professional
4//! switch an equation between the forms, both of which OneNote stores.
5
6use super::link::format_after;
7use super::*;
8use onestore::page::Math;
9
10const OBJECT_START: char = '\u{fdd0}';
11const ARGUMENT_SEPARATOR: char = '\u{fdee}';
12const OBJECT_END: char = '\u{fdef}';
13
14/// The run of math in `text` that UTF-16 `offset` lies in or touches.
15pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option<Range<u32>> {
16 let (mut byte, mut unit) = (0, 0);
17 let mut current: Option<Range<u32>> = None;
18 for span in text.spans() {
19 let end = unit + text.text()[byte..span.end].encode_utf16().count() as u32;
20 if span.format.math == Some(true) {
21 current = Some(
22 current
23 .filter(|zone| zone.end == unit)
24 .map_or(unit, |zone| zone.start)..end,
25 );
26 } else if current
27 .as_ref()
28 .is_some_and(|zone| zone.start <= offset && offset <= zone.end)
29 {
30 return current;
31 } else {
32 current = None;
33 }
34 (byte, unit) = (span.end, end);
35 }
36 current.filter(|zone| zone.start <= offset && offset <= zone.end)
37}
38
39/// An object of an equation holding a position: its range, from its opening control to past
40/// its closing one, and the argument the position lies in.
41#[derive(PartialEq)]
42struct Holder {
43 range: Range<u32>,
44 argument: usize,
45 placeholder: bool,
46}
47
48/// The objects of `text` holding UTF-16 `offset` strictly inside, outermost first.
49fn holders(text: &Paragraph, offset: u32) -> Vec<Holder> {
50 // Each object's range and whether it is a placeholder; open objects and held ones by
51 // index, with the argument reached.
52 let mut objects: Vec<(Range<u32>, bool)> = Vec::new();
53 let (mut open, mut held) = (Vec::new(), None);
54 let mut unit = 0;
55 for c in text.text().chars() {
56 if unit == offset {
57 held = Some(open.clone());
58 }
59 match c {
60 OBJECT_START => {
61 // An equation's empty placeholder, "Type equation here.", is a box OneNote
62 // marks with this symbol.
63 let placeholder = text.format_at(unit + 1).is_ok_and(|format| {
64 format
65 .math_object
66 .as_ref()
67 .is_some_and(|object| object.kind == 11 && object.symbols == ['⬚'])
68 });
69 open.push((objects.len(), 0));
70 objects.push((unit..unit, placeholder));
71 }
72 ARGUMENT_SEPARATOR => {
73 if let Some((_, argument)) = open.last_mut() {
74 *argument += 1;
75 }
76 }
77 OBJECT_END => {
78 if let Some((index, _)) = open.pop() {
79 objects[index].0.end = unit + 1;
80 }
81 }
82 _ => {}
83 }
84 unit += c.len_utf16() as u32;
85 }
86 held.unwrap_or(open)
87 .into_iter()
88 .map(|(index, argument)| Holder {
89 range: objects[index].0.clone(),
90 argument,
91 placeholder: objects[index].1,
92 })
93 .collect()
94}
95
96/// `range` as OneNote 2010's equation editor selects it (`corpus/equation-select`): an end
97/// inside an object not holding the other end in the same argument takes the whole object, as
98/// does an end inside a placeholder, and across paragraphs an object so taken at the end of
99/// its paragraph takes the paragraph's end with it.
100pub(crate) fn widen(
101 document: &TextDocument,
102 range: Range<TextPosition>,
103) -> Result<Range<TextPosition>, EditError> {
104 let text = |paragraph| document.paragraph(paragraph).ok_or(EditError::InvalidRange);
105 let last = text(range.end.paragraph)?;
106 let starts = holders(text(range.start.paragraph)?, range.start.offset);
107 let ends = holders(last, range.end.offset);
108 let common = if range.start.paragraph == range.end.paragraph {
109 starts.iter().zip(&ends).take_while(|(a, b)| a == b).count()
110 } else {
111 0
112 };
113 let level = |holders: &[Holder]| {
114 holders
115 .iter()
116 .position(|holder| holder.placeholder)
117 .map_or(common, |placeholder| placeholder.min(common))
118 };
119 let mut widened = range.clone();
120 if let Some(holder) = starts.get(level(&starts)) {
121 widened.start.offset = holder.range.start;
122 }
123 if let Some(holder) = ends.get(level(&ends)) {
124 widened.end.offset = holder.range.end;
125 let next = range.end.paragraph + 1;
126 let container = |paragraph| document.leaf(paragraph).map(|(container, ..)| container);
127 if range.start.paragraph != range.end.paragraph
128 && widened.end.offset == last.utf16_offset(last.text().len())?
129 && container(next).is_some_and(|next| Some(next) == container(range.end.paragraph))
130 {
131 widened.end = TextPosition {
132 paragraph: next,
133 offset: 0,
134 };
135 }
136 }
137 Ok(widened)
138}
139
140/// The format text typed after an equation in `text` takes: the paragraph's last text's, or
141/// its first run's without the equation editor's.
142pub(super) fn text_after(text: &Paragraph) -> Format {
143 let mut format = text
144 .spans()
145 .iter()
146 .rev()
147 .find(|span| span.format.math != Some(true))
148 .map_or_else(
149 || text.spans()[0].format.clone(),
150 |span| span.format.clone(),
151 );
152 format.math = format.math.map(|_| false);
153 format.embedded_object = None;
154 format.math_object = None;
155 if format.font.as_deref() == Some("Cambria Math") {
156 // OneNote's body font, where the paragraph has no text to take one from.
157 format.font = Some("Calibri".into());
158 format.italic = format.italic.map(|_| false);
159 // The writers' default, where the paragraph has no text to take a language from.
160 format.language = Some(0x409);
161 }
162 format
163}
164
165impl CanvasEditor {
166 /// The paragraph and math run at the caret.
167 fn caret_zone(&self) -> Option<(usize, Range<u32>)> {
168 let [anchor, focus] = self.active_outline().selection.positions;
169 let caret = anchor.min(focus);
170 let text = self.active_outline().document.paragraph(caret.paragraph)?;
171 Some((caret.paragraph, zone(text, caret.offset)?))
172 }
173
174 /// Widens the selection an edit is about to replace as the equation editor selects.
175 pub(super) fn take_objects(&mut self) -> Result<(), EditError> {
176 let [anchor, focus] = self.active_outline().selection.positions;
177 let range = anchor.min(focus)..anchor.max(focus);
178 let widened = widen(&self.active_outline().document, range.clone())?;
179 if widened != range {
180 self.active_outline_mut().selection = Selection {
181 positions: [widened.start, widened.end],
182 affinities: [Affinity::Downstream, Affinity::Upstream],
183 };
184 }
185 Ok(())
186 }
187
188 /// Whether the caret is in an equation, which Linear and Professional act on.
189 pub(crate) fn in_equation(&self) -> bool {
190 self.caret_zone().is_some()
191 }
192
193 /// Professional: builds the equation at the caret up from its linear text.
194 pub fn build_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
195 let Some((paragraph, zone)) = self.caret_zone() else {
196 return Ok(false);
197 };
198 self.rebuild(engine, paragraph, zone, None)
199 }
200
201 /// Linear: shows the equation at the caret in its linear format, as text to edit.
202 pub fn linear_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
203 let Some((paragraph, zone)) = self.caret_zone() else {
204 return Ok(false);
205 };
206 let text = self
207 .active_outline()
208 .document
209 .paragraph(paragraph)
210 .ok_or(EditError::InvalidRange)?;
211 let math = text.slice(zone.clone())?;
212 let Ok(nodes) = Math::parse(&math) else {
213 return Ok(false);
214 };
215 let Some(linear) = Math::linear_paragraph(&nodes, &math.spans()[0].format) else {
216 return Ok(false);
217 };
218 let end = zone.start + linear.utf16_offset(linear.text().len())?;
219 self.replace_zone(engine, paragraph, zone, linear, end)
220 }
221
222 /// Alt+=: the selected text becomes an equation, the equation at the caret builds up, or
223 /// what is typed next at the caret starts one.
224 pub fn insert_equation(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
225 let outline = self.active_outline();
226 let [anchor, focus] = outline.selection.positions;
227 let (start, end) = (anchor.min(focus), anchor.max(focus));
228 if start == end {
229 if self.caret_zone().is_some() {
230 self.build_equation(engine)?;
231 return Ok(());
232 }
233 let format = Math::format(&self.typing_format(start)?);
234 self.pending = Some((outline.id, start, format));
235 return Ok(());
236 }
237 // Across paragraphs each paragraph's part becomes an equation of its own, as in
238 // OneNote 2010, in one undo step.
239 let depth = self.undo.len();
240 let result = (|| {
241 for paragraph in start.paragraph..=end.paragraph {
242 let text = self
243 .active_outline()
244 .document
245 .paragraph(paragraph)
246 .ok_or(EditError::InvalidRange)?;
247 let from = if paragraph == start.paragraph {
248 start.offset
249 } else {
250 0
251 };
252 let to = if paragraph == end.paragraph {
253 end.offset
254 } else {
255 text.utf16_offset(text.text().len())?
256 };
257 if from == to {
258 continue;
259 }
260 let linear = text
261 .slice(from..to)?
262 .project()?
263 .text()
264 .text()
265 .replace('\n', " ");
266 let base = format_after(text, from).cloned().unwrap_or_default();
267 let math = Math::paragraph(&Math::from_linear(&linear), &base);
268 let caret = from + math.utf16_offset(math.text().len())?;
269 self.replace_zone(engine, paragraph, from..to, math, caret)?;
270 }
271 Ok(())
272 })();
273 self.group(depth, false);
274 result
275 }
276
277 /// Builds the equation up after the space typed at `typed` ended part of it, as the
278 /// equation editor builds as it is typed. Only a caret at the end of the equation, or
279 /// only closing its objects before that end, builds; the caret stays as deep in the built
280 /// objects.
281 pub(super) fn build_typed_equation(
282 &mut self,
283 engine: &mut TextEngine,
284 typed: TextPosition,
285 ) -> Result<(), EditorError> {
286 let text = self
287 .active_outline()
288 .document
289 .paragraph(typed.paragraph)
290 .ok_or(EditError::InvalidRange)?;
291 if format_after(text, typed.offset).is_none_or(|format| format.math != Some(true)) {
292 return Ok(());
293 }
294 let Some(zone) = zone(text, typed.offset) else {
295 return Ok(());
296 };
297 let caret = typed.offset + 1;
298 let after = &text.text()[text.byte_offset(caret)?..text.byte_offset(zone.end)?];
299 if !after.chars().all(|c| c == OBJECT_END) {
300 return Ok(());
301 }
302 let depth = after.chars().count() as u32;
303 self.rebuild(engine, typed.paragraph, zone, Some(depth))?;
304 Ok(())
305 }
306
307 /// Right at the end of an equation ending its paragraph leaves it: what is typed next is
308 /// text again.
309 pub(super) fn leave_equation(&mut self) -> Result<bool, EditError> {
310 let outline = self.active_outline();
311 let [anchor, focus] = outline.selection.positions;
312 let Some(text) = outline.document.paragraph(focus.paragraph) else {
313 return Ok(false);
314 };
315 let end = text.utf16_offset(text.text().len())?;
316 let math = self.typing_format(focus)?.math == Some(true);
317 if anchor != focus || focus.offset != end || !math {
318 return Ok(false);
319 }
320 self.pending = Some((outline.id, focus, text_after(text)));
321 Ok(true)
322 }
323
324 /// Rebuilds math run `zone` of paragraph `paragraph` from its linear text. With `depth`,
325 /// the caret goes that many objects before the end of the built run, and into an
326 /// argument left empty there; without, to the run's end.
327 fn rebuild(
328 &mut self,
329 engine: &mut TextEngine,
330 paragraph: usize,
331 zone: Range<u32>,
332 depth: Option<u32>,
333 ) -> Result<bool, EditorError> {
334 let text = self
335 .active_outline()
336 .document
337 .paragraph(paragraph)
338 .ok_or(EditError::InvalidRange)?;
339 let math = text.slice(zone.clone())?;
340 let Some(linear) = Math::parse(&math)
341 .ok()
342 .and_then(|nodes| Math::linear(&nodes))
343 else {
344 return Ok(false);
345 };
346 let nodes = if depth.is_some() {
347 Math::typed(&linear)
348 } else {
349 Math::from_linear(&linear)
350 };
351 let built = Math::paragraph(&nodes, &math.spans()[0].format);
352 if built == math {
353 return Ok(false);
354 }
355 let units: Vec<char> = built.text().chars().collect();
356 let mut caret = units.len() - depth.unwrap_or(0).min(units.len() as u32) as usize;
357 if depth.is_some()
358 && caret >= 2
359 && units[caret - 1] == OBJECT_END
360 && matches!(units[caret - 2], ARGUMENT_SEPARATOR | OBJECT_START)
361 {
362 caret -= 1;
363 }
364 let caret = zone.start
365 + units[..caret]
366 .iter()
367 .map(|c| c.len_utf16() as u32)
368 .sum::<u32>();
369 self.replace_zone(engine, paragraph, zone, built, caret)
370 }
371
372 /// Enter inside an equation, as OneNote 2010's equation editor takes it
373 /// (`corpus/math-edit/native-enter`): in the equation's own row a line break the
374 /// paragraph keeps, in an object's argument a new row of it, the argument becoming an
375 /// equation array where it is not one. False where the caret is not inside an equation.
376 pub(super) fn break_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
377 let caret = self.active_outline().selection.positions[1];
378 let Some((paragraph, zone)) = self.caret_zone() else {
379 return Ok(false);
380 };
381 let text = self
382 .active_outline()
383 .document
384 .paragraph(paragraph)
385 .ok_or(EditError::InvalidRange)?;
386 let units: Vec<char> = text.slice(zone.clone())?.text().chars().collect();
387 let at = text.slice(zone.start..caret.offset)?.text().chars().count();
388 if at == 0 || at == units.len() || !units.contains(&OBJECT_START) {
389 return Ok(false);
390 }
391 // The argument around the caret: from the control before it at its depth to the one
392 // after.
393 let mut depth = 0;
394 let start = units[..at].iter().rposition(|&c| {
395 match c {
396 OBJECT_END => depth += 1,
397 OBJECT_START if depth > 0 => depth -= 1,
398 OBJECT_START | ARGUMENT_SEPARATOR if depth == 0 => return true,
399 _ => {}
400 }
401 false
402 });
403 let Some(start) = start else {
404 // The equation's own row breaks its line, outside the math.
405 let mut updated = text.clone();
406 let break_format = text_after(text);
407 updated.apply(onestore::page::text::Edit {
408 range: caret.offset..caret.offset,
409 replacement: Paragraph::new("\r".into(), break_format),
410 })?;
411 let after = TextPosition {
412 paragraph,
413 offset: caret.offset + 1,
414 };
415 self.rewrite(engine, paragraph, updated, [after; 2].into())?;
416 return Ok(true);
417 };
418 let mut depth = 0;
419 let end = at
420 + units[at..]
421 .iter()
422 .position(|&c| {
423 match c {
424 OBJECT_START => depth += 1,
425 OBJECT_END if depth > 0 => depth -= 1,
426 OBJECT_END | ARGUMENT_SEPARATOR if depth == 0 => return true,
427 _ => {}
428 }
429 false
430 })
431 .ok_or(EditError::InvalidStructure)?;
432 // The object the argument belongs to opens at the start of its first argument.
433 let mut depth = 0;
434 let opening = units[..=start]
435 .iter()
436 .rposition(|&c| {
437 match c {
438 OBJECT_END => depth += 1,
439 OBJECT_START if depth > 0 => depth -= 1,
440 OBJECT_START => return true,
441 _ => {}
442 }
443 false
444 })
445 .ok_or(EditError::InvalidStructure)?;
446 let math = text.slice(zone.clone())?;
447 let offset =
448 |index: usize| -> u32 { units[..index].iter().map(|c| c.len_utf16() as u32).sum() };
449 let kind = math
450 .format_at(offset(opening) + 1)?
451 .math_object
452 .as_ref()
453 .map(|object| object.kind);
454 let format = math.format_at(offset(at))?.clone();
455 let control = |c: char, kind: u32| {
456 let mut format = format.clone();
457 format.math_object = Some(onestore::document::MathObject {
458 kind,
459 arguments: None,
460 columns: Some(1),
461 symbols: vec!['█'],
462 });
463 (c.to_string(), format)
464 };
465 let piece = |range: Range<usize>| -> Result<Paragraph, EditError> {
466 math.slice(offset(range.start)..offset(range.end))
467 };
468 let mut parts = vec![piece(0..start + 1)?];
469 if kind != Some(15) {
470 parts.push(Paragraph::from_runs([control(OBJECT_START, 15)]));
471 }
472 parts.push(piece(start + 1..at)?);
473 parts.push(Paragraph::from_runs([control(ARGUMENT_SEPARATOR, 15)]));
474 parts.push(piece(at..end)?);
475 if kind != Some(15) {
476 parts.push(Paragraph::from_runs([control(OBJECT_END, 15)]));
477 }
478 parts.push(piece(end..units.len())?);
479 let mut broken = parts.remove(0);
480 for part in parts {
481 broken.append(part)?;
482 }
483 let Ok(nodes) = Math::parse(&broken) else {
484 return Ok(false);
485 };
486 let built = Math::paragraph(&nodes, &math.spans()[0].format);
487 // The caret starts the new row: after as many characters other than spaces as came
488 // before it.
489 let before = broken
490 .text()
491 .chars()
492 .take(at + if kind == Some(15) { 1 } else { 2 });
493 let count = before.filter(|c| *c != ' ').count();
494 let mut seen = 0;
495 let mut caret = zone.start;
496 for c in built.text().chars() {
497 if seen == count {
498 break;
499 }
500 seen += usize::from(c != ' ');
501 caret += c.len_utf16() as u32;
502 }
503 self.replace_zone(engine, paragraph, zone, built, caret)
504 }
505
506 fn replace_zone(
507 &mut self,
508 engine: &mut TextEngine,
509 paragraph: usize,
510 zone: Range<u32>,
511 math: Paragraph,
512 caret: u32,
513 ) -> Result<bool, EditorError> {
514 let mut text = self
515 .active_outline()
516 .document
517 .paragraph(paragraph)
518 .ok_or(EditError::InvalidRange)?
519 .clone();
520 text.apply(onestore::page::text::Edit {
521 range: zone,
522 replacement: math,
523 })?;
524 let caret = TextPosition {
525 paragraph,
526 offset: caret,
527 };
528 self.rewrite(engine, paragraph, text, [caret; 2].into())?;
529 Ok(true)
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use onestore::document::MathObject;
537 use onestore::page::PageObject;
538
539 type Runs = Vec<(String, Option<MathObject>, Option<bool>)>;
540
541 fn runs(text: &Paragraph) -> Runs {
542 let mut start = 0;
543 text.spans()
544 .iter()
545 .map(|span| {
546 let run = text.text()[start..span.end].to_owned();
547 start = span.end;
548 (run, span.format.math_object.clone(), span.format.italic)
549 })
550 .collect()
551 }
552
553 fn equations(bytes: &[u8]) -> Vec<Runs> {
554 let store = onestore::Store::parse(bytes).unwrap();
555 let index = onestore::RevisionIndex::parse(&store).unwrap();
556 let document = onestore::document::Document::parse(&index).unwrap();
557 let (space, _) = document.pages().unwrap()[0];
558 Page::from_space(&document, space)
559 .unwrap()
560 .objects
561 .iter()
562 .filter_map(|object| match object {
563 PageObject::Outline(outline) => Some(outline),
564 _ => None,
565 })
566 .flat_map(|outline| &outline.paragraphs)
567 .filter_map(|paragraph| paragraph.text())
568 .filter(|text| Math::is_equation(&text.text))
569 .map(|text| {
570 // Text typed after an equation, outside it, is not the editor's.
571 let mut runs = runs(&text.text);
572 runs.retain(|(_, object, _)| object.is_some());
573 runs
574 })
575 .collect()
576 }
577
578 /// Alt+= and typing, each space its own key, store what OneNote 2010's equation editor
579 /// stored for the same keys (`tools/native_math.py`), building up as they are typed.
580 #[test]
581 fn typed_equations_build_up_as_the_equation_editor_stores_them() {
582 let sessions: [(&[u8], &[Option<&str>]); 4] = [
583 (
584 include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one"),
585 &[
586 Some("a_1+b_2"),
587 Some("x_i^2"),
588 Some("(a+b)"),
589 Some("\\int_0^1 x dx"),
590 Some("\\sum_(i=1)^n i"),
591 ],
592 ),
593 (
594 include_bytes!("../../../../corpus/math-edit/native-editor-2/notebook/links.one"),
595 &[
596 Some("\\sqrt x+1"),
597 Some("\\cbrt(x)"),
598 Some("\\sqrt(n&x)"),
599 Some("a/b"),
600 Some("\\lim_(x\\to 0) f(x)"),
601 Some("\\prod_(k=1)^n k"),
602 Some("[a+b]"),
603 Some("\\overline(x)"),
604 Some("x\\hat"),
605 ],
606 ),
607 (
608 include_bytes!("../../../../corpus/math-edit/native-editor-3/notebook/links.one"),
609 &[
610 Some("\\matrix(1&2@3&4)"),
611 Some("\\eqarray(x&=1@y&=2)"),
612 Some("x\\above 2"),
613 Some("x\\below 2"),
614 Some("\\box(x)"),
615 Some("\\rect(x)"),
616 None,
617 Some("\\iint x dx dy"),
618 Some("f(x)/(x^2+1)"),
619 Some("\\sum^n x"),
620 ],
621 ),
622 (
623 include_bytes!("../../../../corpus/math-edit/native-editor-4/notebook/links.one"),
624 &[
625 Some("a_1+b_2"),
626 // The editor was still starting this one and kept its placeholder.
627 None,
628 Some("\\sum_(i=1)^n i"),
629 Some("\\int_0^1 x dx"),
630 Some("\\sqrt x+1"),
631 Some("\\cbrt(x)"),
632 Some("\\sqrt(n&x)"),
633 Some("a/b"),
634 Some("\\prod_(k=1)^n k"),
635 Some("[a+b]"),
636 Some("\\overline(x)"),
637 Some("x\\hat"),
638 Some("\\matrix(1&2@3&4)"),
639 Some("\\eqarray(x&=1@y&=2)"),
640 Some("x\\above 2"),
641 Some("x\\below 2"),
642 Some("\\box(x)"),
643 Some("\\rect(x)"),
644 Some("f(x)/(x^2+1)"),
645 Some("\\sum^n x"),
646 Some("e^(x+1)"),
647 Some("\\alpha+\\beta"),
648 Some("(a+b)/(c+d)"),
649 Some("x^2+y^2=z^2"),
650 // A function applied to a limit, an object this reading does not build.
651 None,
652 Some("{a+b}"),
653 ],
654 ),
655 ];
656 let mut engine = TextEngine::default();
657 for (bytes, typed) in sessions {
658 let native = equations(bytes);
659 assert_eq!(native.len(), typed.len());
660 for (native, typed) in native.iter().zip(typed) {
661 let Some(typed) = typed else {
662 continue;
663 };
664 let mut editor = CanvasEditor::new(
665 &mut engine,
666 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())])
667 .unwrap(),
668 400.0,
669 )
670 .unwrap();
671 editor.insert_equation(&mut engine).unwrap();
672 for (index, word) in format!("{typed} ").split(' ').enumerate() {
673 if index > 0 {
674 editor.insert(&mut engine, " ").unwrap();
675 }
676 if !word.is_empty() {
677 editor.insert(&mut engine, word).unwrap();
678 }
679 }
680 let text = editor.active_outline().document.paragraph(0).unwrap();
681 assert_eq!(&runs(text), native, "{typed}");
682 }
683 }
684 }
685}