| 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 | |
| 6 | use super::link::format_after; |
| 7 | use super::*; |
| 8 | use onestore::page::Math; |
| 9 | |
| 10 | const OBJECT_START: char = '\u{fdd0}'; |
| 11 | const ARGUMENT_SEPARATOR: char = '\u{fdee}'; |
| 12 | const OBJECT_END: char = '\u{fdef}'; |
| 13 | |
| 14 | /// The run of math in `text` that UTF-16 `offset` lies in or touches. |
| 15 | pub(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)] |
| 42 | struct 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. |
| 49 | fn 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. |
| 100 | pub(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. |
| 142 | pub(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 | |
| 165 | impl 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)] |
| 534 | mod 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 | } |