diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 1da730d746a2bdae2cecb646bdabd6a064670fb9..793babecd34aa7639f4b5435ccea18be46eddaf8 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -43,7 +43,7 @@ harness also accepts `--client-profile release`. | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | | `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups | -| `page::Page`, `page::Paragraph`, `page::Ink` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; content outside the model is retained as `Unsupported` | +| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | @@ -174,8 +174,8 @@ cargo run --example random_edit -- /tmp/one-demo/synthetic.one /tmp/edited.one 4 The report contains readable pages, document JSON, assets, source identities and coordinates. It preserves paragraph nesting, lists, tables, links and tags. Historical contexts, recycle-bin pages and default templates are represented -separately. Native ink is decoded to strokes; structured equations retain their -source data and appear explicitly as opaque content. The report is a reading view; its native +separately. Native ink is decoded to strokes and equations to MathML; both +retain their source data. The report is a reading view; its native PDF references supply the original canvas layout. Read and validate a snapshot before interpreting its graph: diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index ee7dd354c0cd1e9ea8cc777ca9876325ca4e7724..1edbd7d627ce6c6d63bf2045d2659e32b0d27b54 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -125,6 +125,18 @@ format_fields! { space_after: f32, line_spacing: f32, list_spacing: f32, + math_object: MathObject, +} + +/// The inline math object a run belongs to, from its text-run data: `kind` follows the +/// `OBJECTTYPE` values of the Windows text object model (31 superscript, 16 fraction, …), +/// `arguments` counts the object's arguments on its opening run, and `symbols` are the +/// characters the object builds with (a bracket pair, an n-ary operator, `^` or `/`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MathObject { + pub kind: u32, + pub arguments: Option, + pub symbols: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -194,12 +206,46 @@ impl Revision<'_> { return Err(invalid("Text-run boundary splits a surrogate pair")); } let fragment = &text[start..byte]; - let format = run + let mut format = run .format .map(style) .transpose()? .unwrap_or(&default) .inherit(&base); + if let Some(set) = run.extra_set.and_then(|index| node.extra.get(index)) { + let scalar = |id: u32| { + set.iter().find_map(|field| match field.value { + FieldValue::Bytes(bytes) if field.id == id => Some(bytes), + _ => None, + }) + }; + if let Some(kind) = scalar(0x1400344f) { + format.math_object = Some(MathObject { + kind: u32::from_le_bytes( + kind.try_into() + .map_err(|_| invalid("Math object type has an invalid length"))?, + ), + arguments: scalar(0x14003450) + .map(|b| { + b.try_into().map(u32::from_le_bytes).map_err(|_| { + invalid("Math argument count has an invalid length") + }) + }) + .transpose()?, + symbols: [0x10003453, 0x10003454, 0x10003455] + .into_iter() + .filter_map(scalar) + .map(|b| { + b.try_into() + .ok() + .map(u16::from_le_bytes) + .and_then(|unit| char::from_u32(u32::from(unit))) + .ok_or_else(|| invalid("Math symbol is not a character")) + }) + .collect::>()?, + }); + } + } let mut link = None; if format.hyperlink != Some(true) { target = None; @@ -943,6 +989,7 @@ impl<'a> Element<'a> { space_after: f.float(0x1400342f, 36.0)?, line_spacing: f.float(0x14003430, 36.0)?, list_spacing: f.float(0x14001ccb, 36.0)?, + math_object: None, }; let mut tags = Vec::new(); if let Some(property) = f.take(0x40003489) { diff --git a/crates/onestore/src/page/math.rs b/crates/onestore/src/page/math.rs new file mode 100644 index 0000000000000000000000000000000000000000..c1205f2e1ba27412b1b69f5e0245fd5667c6bace --- /dev/null +++ b/crates/onestore/src/page/math.rs @@ -0,0 +1,217 @@ +//! Equations as OneNote stores them: a linear text where U+FDD0 opens an inline object, +//! U+FDEE separates its arguments and U+FDEF closes it, with the object's kind on the run +//! data of the opening character. `Math::parse` builds the tree and `mathml` renders it the +//! way OneNote's own export does for the kinds a native fixture has verified (superscript +//! and fraction); other kinds follow the text object model's meaning without a native check. + +use super::text::Paragraph; +use crate::document::MathObject; + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum Math { + Identifier(char), + Number(String), + Operator(char), + Object { + kind: u32, + symbols: Vec, + arguments: Vec>, + }, +} + +const OBJECT_START: char = '\u{fdd0}'; +const ARGUMENT_SEPARATOR: char = '\u{fdee}'; +const OBJECT_END: char = '\u{fdef}'; + +impl Math { + /// Whether a paragraph's text is stored as an equation. + pub fn is_equation(paragraph: &Paragraph) -> bool { + paragraph.text().contains(OBJECT_START) + || paragraph + .spans() + .iter() + .any(|span| span.format.math == Some(true)) + } + + /// The equation a paragraph's text holds, as a sequence of nodes. + pub fn parse(paragraph: &Paragraph) -> Result, crate::Error> { + let invalid = |message| crate::Error { offset: 0, message }; + let object_at = |offset: usize| -> Option<&MathObject> { + paragraph + .format_at(u32::try_from(offset).ok()?) + .ok() + .and_then(|f| f.math_object.as_ref()) + }; + struct Frame { + kind: u32, + symbols: Vec, + arguments: Vec>, + } + let mut frames: Vec = Vec::new(); + let mut sequences: Vec> = vec![Vec::new()]; + let mut number = String::new(); + let mut offset = 0; + for character in paragraph.text().chars() { + let here = offset; + offset += character.len_utf16(); + if character.is_ascii_digit() { + number.push(character); + continue; + } + if !number.is_empty() { + let number = std::mem::take(&mut number); + sequences.last_mut().unwrap().push(Math::Number(number)); + } + match character { + OBJECT_START => { + // The span lookup treats a boundary offset as the span ending there, so the + // opening character's own span is the one containing the offset after it. + let object = object_at(here + 1) + .ok_or_else(|| invalid("An equation object has no run data"))?; + frames.push(Frame { + kind: object.kind, + symbols: object.symbols.clone(), + arguments: Vec::new(), + }); + sequences.push(Vec::new()); + } + ARGUMENT_SEPARATOR => { + let argument = sequences.pop().unwrap(); + frames + .last_mut() + .ok_or_else(|| invalid("An equation argument separator has no object"))? + .arguments + .push(argument); + sequences.push(Vec::new()); + } + OBJECT_END => { + let argument = sequences.pop().unwrap(); + let mut frame = frames + .pop() + .ok_or_else(|| invalid("An equation object ends before it starts"))?; + frame.arguments.push(argument); + sequences.last_mut().unwrap().push(Math::Object { + kind: frame.kind, + symbols: frame.symbols, + arguments: frame.arguments, + }); + } + c if c.is_whitespace() => {} + c if c.is_alphabetic() => sequences + .last_mut() + .unwrap() + .push(Math::Identifier(plain(c))), + c => sequences.last_mut().unwrap().push(Math::Operator(c)), + } + } + if !number.is_empty() { + sequences.last_mut().unwrap().push(Math::Number(number)); + } + if !frames.is_empty() { + return Err(invalid("An equation object never ends")); + } + Ok(sequences.pop().unwrap()) + } + + /// MathML for a node sequence, without the `math` wrapper, in OneNote's export form + /// (`mml:` prefixed elements, one `mi` per letter). + pub fn mathml(nodes: &[Math]) -> String { + let mut out = String::new(); + for node in nodes { + node.write(&mut out); + } + out + } + + fn write(&self, out: &mut String) { + match self { + Math::Identifier(c) => tag(out, "mi", &c.to_string()), + Math::Number(n) => tag(out, "mn", n), + Math::Operator(c) => tag(out, "mo", &c.to_string()), + Math::Object { + kind, + symbols, + arguments, + } => { + let argument = |out: &mut String, index: usize| match arguments + .get(index) + .map(Vec::as_slice) + { + Some([single]) => single.write(out), + Some(many) => { + out.push_str(""); + for node in many { + node.write(out); + } + out.push_str(""); + } + None => out.push_str(""), + }; + let wrapped = |out: &mut String, element: &str, order: &[usize]| { + out.push_str(&format!("")); + for index in order { + argument(out, *index); + } + out.push_str(&format!("")); + }; + match (kind, arguments.len()) { + (31, 2) => wrapped(out, "msup", &[0, 1]), + (29, 2) => wrapped(out, "msub", &[0, 1]), + (30, 3) => wrapped(out, "msubsup", &[0, 1, 2]), + (16, 2) | (26, 2) => wrapped(out, "mfrac", &[0, 1]), + (25, 2) if !arguments[0].is_empty() => wrapped(out, "mroot", &[1, 0]), + (25, _) => wrapped(out, "msqrt", &[arguments.len() - 1]), + (19, 2) => wrapped(out, "munder", &[0, 1]), + (33, 2) => wrapped(out, "mover", &[0, 1]), + (13, _) => { + out.push_str(""); + if let Some(open) = symbols.first() { + tag(out, "mo", &open.to_string()); + } + for index in 0..arguments.len() { + argument(out, index); + } + if let Some(close) = symbols.get(1) { + tag(out, "mo", &close.to_string()); + } + out.push_str(""); + } + _ => { + out.push_str(""); + for symbol in symbols { + tag(out, "mo", &symbol.to_string()); + } + for index in 0..arguments.len() { + argument(out, index); + } + out.push_str(""); + } + } + } + } + } +} + +fn tag(out: &mut String, element: &str, content: &str) { + out.push_str(&format!("")); + for c in content.chars() { + match c { + '<' => out.push_str("<"), + '&' => out.push_str("&"), + '>' => out.push_str(">"), + c => out.push(c), + } + } + out.push_str(&format!("")); +} + +/// OneNote exports mathematical italic Latin letters as their plain letters and leaves every +/// other alphabet (Greek, double-struck, …) as stored. +fn plain(c: char) -> char { + match u32::from(c) { + code @ 0x1d434..=0x1d44d => char::from_u32(code - 0x1d434 + u32::from('A')).unwrap(), + code @ 0x1d44e..=0x1d467 => char::from_u32(code - 0x1d44e + u32::from('a')).unwrap(), + 0x210e => 'h', + _ => c, + } +} diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 169dffbaf48731a81cf251f5e39163e4e07ea105..642e28bea9cca331e8adafe3f23e96e5fb2b9e4e 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -12,9 +12,11 @@ use std::{ pub mod ink; pub mod link; +pub mod math; pub mod text; pub use ink::{Ink, InkStroke}; +pub use math::Math; pub(crate) mod write; pub use text::Paragraph; diff --git a/crates/onestore/tests/page_math.rs b/crates/onestore/tests/page_math.rs new file mode 100644 index 0000000000000000000000000000000000000000..bae4c525ecf5c03c0683dcc5225cf6f7343112aa --- /dev/null +++ b/crates/onestore/tests/page_math.rs @@ -0,0 +1,76 @@ +use onestore::{ + RevisionIndex, Store, + document::Document, + page::{Math, Page, PageObject}, +}; + +/// Three equations authored through OneNote 2010's equation editor, whose COM read exports +/// MathML (`corpus/m6/native-math-01`). +const NATIVE: &[u8] = include_bytes!("../../../corpus/m6/native-math-01/notebook/synthetic.one"); +const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-math-01/read/page-000.xml"); + +fn native_mathml() -> Vec { + NATIVE_READ + .split("').unwrap() + 1..rest.find("").unwrap()]; + let mut decoded = String::new(); + let mut remainder = body; + while let Some(start) = remainder.find("&#") { + decoded.push_str(&remainder[..start]); + let end = start + remainder[start..].find(';').unwrap(); + let code: u32 = remainder[start + 2..end].parse().unwrap(); + decoded.push(char::from_u32(code).unwrap()); + remainder = &remainder[end + 1..]; + } + decoded.push_str(remainder); + decoded + }) + .collect() +} + +#[test] +fn native_equations_render_to_the_mathml_onenote_exports() { + let store = Store::parse(NATIVE).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let page = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| Page::from_space(&document, space).unwrap()) + .find(|page| page.title == "Equation controls") + .unwrap(); + let mut rendered = Vec::new(); + for object in &page.objects { + let PageObject::Outline(outline) = object else { + continue; + }; + for paragraph in &outline.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + if !Math::is_equation(&text.text) { + continue; + } + // Inline math follows ordinary text in the same paragraph; only the math runs + // form the equation. + let mut at = 0; + let math_runs: Vec<(String, onestore::document::Format)> = text + .text + .spans() + .iter() + .map(|span| { + let piece = text.text.text()[at..span.end].to_owned(); + at = span.end; + (piece, span.format.clone()) + }) + .filter(|(_, format)| format.math == Some(true)) + .collect(); + let equation = onestore::page::Paragraph::from_runs(math_runs); + rendered.push(Math::mathml(&Math::parse(&equation).unwrap())); + } + } + assert_eq!(rendered, native_mathml()); +}