| author | |
| committer | |
| log | db49720208e16a932d4c13ebe1172ab872638ac7 |
| tree | db0d588f0b60bfdbc452233f7aca5c66ea8ab638 |
| parent | 237d4668ad21d8c6275da60e59bc4acb066fb853 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
An equation is stored as linear text where U+FDD0 opens an inline object,
U+FDEE separates its arguments and U+FDEF closes it, with the object's kind
(the text object model's OBJECTTYPE: 31 superscript, 16 fraction, …),
argument count and building symbols on the text-run data of the opening
character. The reader now carries that data on each run's format as
Format::math_object, and page::Math parses a paragraph into a tree of
identifiers, numbers, operators and objects and renders it as MathML in the
form OneNote's own export uses (mml: prefix, one mi per letter, math-italic
Latin letters plain, other alphabets as stored, single-node arguments bare
and multi-node ones in mrow).
corpus/m6/native-math-01 is the oracle: its three equation-editor
expressions render byte for byte to the MathML the COM read exported
(superscripts, a fraction and inline Greek). Subscripts, radicals, limits
and brackets follow the OBJECTTYPE meaning without a native check yet.
Assisted-by: claude-fable-5.15 files changed, 346 insertions(+), 4 deletions(-)
crates/onestore/README.md+3-3| ... | ... | @@ -43,7 +43,7 @@ harness also accepts `--client-profile release`. |
| 43 | 43 | | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | |
| 44 | 44 | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | |
| 45 | 45 | | `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 | |
| 46 | | `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` | | |
| 46 | | `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` | | |
| 47 | 47 | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | |
| 48 | 48 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 49 | 49 | | `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 |
| 174 | 174 | The report contains readable pages, document JSON, assets, source identities and |
| 175 | 175 | coordinates. It preserves paragraph nesting, lists, tables, links and tags. |
| 176 | 176 | Historical contexts, recycle-bin pages and default templates are represented |
| 177 | separately. Native ink is decoded to strokes; structured equations retain their | |
| 178 | source data and appear explicitly as opaque content. The report is a reading view; its native | |
| 177 | separately. Native ink is decoded to strokes and equations to MathML; both | |
| 178 | retain their source data. The report is a reading view; its native | |
| 179 | 179 | PDF references supply the original canvas layout. |
| 180 | 180 | |
| 181 | 181 | Read and validate a snapshot before interpreting its graph: |
crates/onestore/src/document.rs+48-1| ... | ... | @@ -125,6 +125,18 @@ format_fields! { |
| 125 | 125 | space_after: f32, |
| 126 | 126 | line_spacing: f32, |
| 127 | 127 | list_spacing: f32, |
| 128 | math_object: MathObject, | |
| 129 | } | |
| 130 | ||
| 131 | /// The inline math object a run belongs to, from its text-run data: `kind` follows the | |
| 132 | /// `OBJECTTYPE` values of the Windows text object model (31 superscript, 16 fraction, …), | |
| 133 | /// `arguments` counts the object's arguments on its opening run, and `symbols` are the | |
| 134 | /// characters the object builds with (a bracket pair, an n-ary operator, `^` or `/`). | |
| 135 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 136 | pub struct MathObject { | |
| 137 | pub kind: u32, | |
| 138 | pub arguments: Option<u32>, | |
| 139 | pub symbols: Vec<char>, | |
| 128 | 140 | } |
| 129 | 141 | |
| 130 | 142 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| ... | ... | @@ -194,12 +206,46 @@ impl Revision<'_> { |
| 194 | 206 | return Err(invalid("Text-run boundary splits a surrogate pair")); |
| 195 | 207 | } |
| 196 | 208 | let fragment = &text[start..byte]; |
| 197 | let format = run | |
| 209 | let mut format = run | |
| 198 | 210 | .format |
| 199 | 211 | .map(style) |
| 200 | 212 | .transpose()? |
| 201 | 213 | .unwrap_or(&default) |
| 202 | 214 | .inherit(&base); |
| 215 | if let Some(set) = run.extra_set.and_then(|index| node.extra.get(index)) { | |
| 216 | let scalar = |id: u32| { | |
| 217 | set.iter().find_map(|field| match field.value { | |
| 218 | FieldValue::Bytes(bytes) if field.id == id => Some(bytes), | |
| 219 | _ => None, | |
| 220 | }) | |
| 221 | }; | |
| 222 | if let Some(kind) = scalar(0x1400344f) { | |
| 223 | format.math_object = Some(MathObject { | |
| 224 | kind: u32::from_le_bytes( | |
| 225 | kind.try_into() | |
| 226 | .map_err(|_| invalid("Math object type has an invalid length"))?, | |
| 227 | ), | |
| 228 | arguments: scalar(0x14003450) | |
| 229 | .map(|b| { | |
| 230 | b.try_into().map(u32::from_le_bytes).map_err(|_| { | |
| 231 | invalid("Math argument count has an invalid length") | |
| 232 | }) | |
| 233 | }) | |
| 234 | .transpose()?, | |
| 235 | symbols: [0x10003453, 0x10003454, 0x10003455] | |
| 236 | .into_iter() | |
| 237 | .filter_map(scalar) | |
| 238 | .map(|b| { | |
| 239 | b.try_into() | |
| 240 | .ok() | |
| 241 | .map(u16::from_le_bytes) | |
| 242 | .and_then(|unit| char::from_u32(u32::from(unit))) | |
| 243 | .ok_or_else(|| invalid("Math symbol is not a character")) | |
| 244 | }) | |
| 245 | .collect::<Result<_>>()?, | |
| 246 | }); | |
| 247 | } | |
| 248 | } | |
| 203 | 249 | let mut link = None; |
| 204 | 250 | if format.hyperlink != Some(true) { |
| 205 | 251 | target = None; |
| ... | ... | @@ -943,6 +989,7 @@ impl<'a> Element<'a> { |
| 943 | 989 | space_after: f.float(0x1400342f, 36.0)?, |
| 944 | 990 | line_spacing: f.float(0x14003430, 36.0)?, |
| 945 | 991 | list_spacing: f.float(0x14001ccb, 36.0)?, |
| 992 | math_object: None, | |
| 946 | 993 | }; |
| 947 | 994 | let mut tags = Vec::new(); |
| 948 | 995 | if let Some(property) = f.take(0x40003489) { |
crates/onestore/src/page/math.rs created+217| ... | ... | @@ -0,0 +1,217 @@ |
| 1 | //! Equations as OneNote stores them: a linear text where U+FDD0 opens an inline object, | |
| 2 | //! U+FDEE separates its arguments and U+FDEF closes it, with the object's kind on the run | |
| 3 | //! data of the opening character. `Math::parse` builds the tree and `mathml` renders it the | |
| 4 | //! way OneNote's own export does for the kinds a native fixture has verified (superscript | |
| 5 | //! and fraction); other kinds follow the text object model's meaning without a native check. | |
| 6 | ||
| 7 | use super::text::Paragraph; | |
| 8 | use crate::document::MathObject; | |
| 9 | ||
| 10 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 11 | pub enum Math { | |
| 12 | Identifier(char), | |
| 13 | Number(String), | |
| 14 | Operator(char), | |
| 15 | Object { | |
| 16 | kind: u32, | |
| 17 | symbols: Vec<char>, | |
| 18 | arguments: Vec<Vec<Math>>, | |
| 19 | }, | |
| 20 | } | |
| 21 | ||
| 22 | const OBJECT_START: char = '\u{fdd0}'; | |
| 23 | const ARGUMENT_SEPARATOR: char = '\u{fdee}'; | |
| 24 | const OBJECT_END: char = '\u{fdef}'; | |
| 25 | ||
| 26 | impl Math { | |
| 27 | /// Whether a paragraph's text is stored as an equation. | |
| 28 | pub fn is_equation(paragraph: &Paragraph) -> bool { | |
| 29 | paragraph.text().contains(OBJECT_START) | |
| 30 | || paragraph | |
| 31 | .spans() | |
| 32 | .iter() | |
| 33 | .any(|span| span.format.math == Some(true)) | |
| 34 | } | |
| 35 | ||
| 36 | /// The equation a paragraph's text holds, as a sequence of nodes. | |
| 37 | pub fn parse(paragraph: &Paragraph) -> Result<Vec<Math>, crate::Error> { | |
| 38 | let invalid = |message| crate::Error { offset: 0, message }; | |
| 39 | let object_at = |offset: usize| -> Option<&MathObject> { | |
| 40 | paragraph | |
| 41 | .format_at(u32::try_from(offset).ok()?) | |
| 42 | .ok() | |
| 43 | .and_then(|f| f.math_object.as_ref()) | |
| 44 | }; | |
| 45 | struct Frame { | |
| 46 | kind: u32, | |
| 47 | symbols: Vec<char>, | |
| 48 | arguments: Vec<Vec<Math>>, | |
| 49 | } | |
| 50 | let mut frames: Vec<Frame> = Vec::new(); | |
| 51 | let mut sequences: Vec<Vec<Math>> = vec![Vec::new()]; | |
| 52 | let mut number = String::new(); | |
| 53 | let mut offset = 0; | |
| 54 | for character in paragraph.text().chars() { | |
| 55 | let here = offset; | |
| 56 | offset += character.len_utf16(); | |
| 57 | if character.is_ascii_digit() { | |
| 58 | number.push(character); | |
| 59 | continue; | |
| 60 | } | |
| 61 | if !number.is_empty() { | |
| 62 | let number = std::mem::take(&mut number); | |
| 63 | sequences.last_mut().unwrap().push(Math::Number(number)); | |
| 64 | } | |
| 65 | match character { | |
| 66 | OBJECT_START => { | |
| 67 | // The span lookup treats a boundary offset as the span ending there, so the | |
| 68 | // opening character's own span is the one containing the offset after it. | |
| 69 | let object = object_at(here + 1) | |
| 70 | .ok_or_else(|| invalid("An equation object has no run data"))?; | |
| 71 | frames.push(Frame { | |
| 72 | kind: object.kind, | |
| 73 | symbols: object.symbols.clone(), | |
| 74 | arguments: Vec::new(), | |
| 75 | }); | |
| 76 | sequences.push(Vec::new()); | |
| 77 | } | |
| 78 | ARGUMENT_SEPARATOR => { | |
| 79 | let argument = sequences.pop().unwrap(); | |
| 80 | frames | |
| 81 | .last_mut() | |
| 82 | .ok_or_else(|| invalid("An equation argument separator has no object"))? | |
| 83 | .arguments | |
| 84 | .push(argument); | |
| 85 | sequences.push(Vec::new()); | |
| 86 | } | |
| 87 | OBJECT_END => { | |
| 88 | let argument = sequences.pop().unwrap(); | |
| 89 | let mut frame = frames | |
| 90 | .pop() | |
| 91 | .ok_or_else(|| invalid("An equation object ends before it starts"))?; | |
| 92 | frame.arguments.push(argument); | |
| 93 | sequences.last_mut().unwrap().push(Math::Object { | |
| 94 | kind: frame.kind, | |
| 95 | symbols: frame.symbols, | |
| 96 | arguments: frame.arguments, | |
| 97 | }); | |
| 98 | } | |
| 99 | c if c.is_whitespace() => {} | |
| 100 | c if c.is_alphabetic() => sequences | |
| 101 | .last_mut() | |
| 102 | .unwrap() | |
| 103 | .push(Math::Identifier(plain(c))), | |
| 104 | c => sequences.last_mut().unwrap().push(Math::Operator(c)), | |
| 105 | } | |
| 106 | } | |
| 107 | if !number.is_empty() { | |
| 108 | sequences.last_mut().unwrap().push(Math::Number(number)); | |
| 109 | } | |
| 110 | if !frames.is_empty() { | |
| 111 | return Err(invalid("An equation object never ends")); | |
| 112 | } | |
| 113 | Ok(sequences.pop().unwrap()) | |
| 114 | } | |
| 115 | ||
| 116 | /// MathML for a node sequence, without the `math` wrapper, in OneNote's export form | |
| 117 | /// (`mml:` prefixed elements, one `mi` per letter). | |
| 118 | pub fn mathml(nodes: &[Math]) -> String { | |
| 119 | let mut out = String::new(); | |
| 120 | for node in nodes { | |
| 121 | node.write(&mut out); | |
| 122 | } | |
| 123 | out | |
| 124 | } | |
| 125 | ||
| 126 | fn write(&self, out: &mut String) { | |
| 127 | match self { | |
| 128 | Math::Identifier(c) => tag(out, "mi", &c.to_string()), | |
| 129 | Math::Number(n) => tag(out, "mn", n), | |
| 130 | Math::Operator(c) => tag(out, "mo", &c.to_string()), | |
| 131 | Math::Object { | |
| 132 | kind, | |
| 133 | symbols, | |
| 134 | arguments, | |
| 135 | } => { | |
| 136 | let argument = |out: &mut String, index: usize| match arguments | |
| 137 | .get(index) | |
| 138 | .map(Vec::as_slice) | |
| 139 | { | |
| 140 | Some([single]) => single.write(out), | |
| 141 | Some(many) => { | |
| 142 | out.push_str("<mml:mrow>"); | |
| 143 | for node in many { | |
| 144 | node.write(out); | |
| 145 | } | |
| 146 | out.push_str("</mml:mrow>"); | |
| 147 | } | |
| 148 | None => out.push_str("<mml:mrow/>"), | |
| 149 | }; | |
| 150 | let wrapped = |out: &mut String, element: &str, order: &[usize]| { | |
| 151 | out.push_str(&format!("<mml:{element}>")); | |
| 152 | for index in order { | |
| 153 | argument(out, *index); | |
| 154 | } | |
| 155 | out.push_str(&format!("</mml:{element}>")); | |
| 156 | }; | |
| 157 | match (kind, arguments.len()) { | |
| 158 | (31, 2) => wrapped(out, "msup", &[0, 1]), | |
| 159 | (29, 2) => wrapped(out, "msub", &[0, 1]), | |
| 160 | (30, 3) => wrapped(out, "msubsup", &[0, 1, 2]), | |
| 161 | (16, 2) | (26, 2) => wrapped(out, "mfrac", &[0, 1]), | |
| 162 | (25, 2) if !arguments[0].is_empty() => wrapped(out, "mroot", &[1, 0]), | |
| 163 | (25, _) => wrapped(out, "msqrt", &[arguments.len() - 1]), | |
| 164 | (19, 2) => wrapped(out, "munder", &[0, 1]), | |
| 165 | (33, 2) => wrapped(out, "mover", &[0, 1]), | |
| 166 | (13, _) => { | |
| 167 | out.push_str("<mml:mrow>"); | |
| 168 | if let Some(open) = symbols.first() { | |
| 169 | tag(out, "mo", &open.to_string()); | |
| 170 | } | |
| 171 | for index in 0..arguments.len() { | |
| 172 | argument(out, index); | |
| 173 | } | |
| 174 | if let Some(close) = symbols.get(1) { | |
| 175 | tag(out, "mo", &close.to_string()); | |
| 176 | } | |
| 177 | out.push_str("</mml:mrow>"); | |
| 178 | } | |
| 179 | _ => { | |
| 180 | out.push_str("<mml:mrow>"); | |
| 181 | for symbol in symbols { | |
| 182 | tag(out, "mo", &symbol.to_string()); | |
| 183 | } | |
| 184 | for index in 0..arguments.len() { | |
| 185 | argument(out, index); | |
| 186 | } | |
| 187 | out.push_str("</mml:mrow>"); | |
| 188 | } | |
| 189 | } | |
| 190 | } | |
| 191 | } | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | fn tag(out: &mut String, element: &str, content: &str) { | |
| 196 | out.push_str(&format!("<mml:{element}>")); | |
| 197 | for c in content.chars() { | |
| 198 | match c { | |
| 199 | '<' => out.push_str("&lt;"), | |
| 200 | '&' => out.push_str("&amp;"), | |
| 201 | '>' => out.push_str("&gt;"), | |
| 202 | c => out.push(c), | |
| 203 | } | |
| 204 | } | |
| 205 | out.push_str(&format!("</mml:{element}>")); | |
| 206 | } | |
| 207 | ||
| 208 | /// OneNote exports mathematical italic Latin letters as their plain letters and leaves every | |
| 209 | /// other alphabet (Greek, double-struck, …) as stored. | |
| 210 | fn plain(c: char) -> char { | |
| 211 | match u32::from(c) { | |
| 212 | code @ 0x1d434..=0x1d44d => char::from_u32(code - 0x1d434 + u32::from('A')).unwrap(), | |
| 213 | code @ 0x1d44e..=0x1d467 => char::from_u32(code - 0x1d44e + u32::from('a')).unwrap(), | |
| 214 | 0x210e => 'h', | |
| 215 | _ => c, | |
| 216 | } | |
| 217 | } |
crates/onestore/src/page/mod.rs+2| ... | ... | @@ -12,9 +12,11 @@ use std::{ |
| 12 | 12 | |
| 13 | 13 | pub mod ink; |
| 14 | 14 | pub mod link; |
| 15 | pub mod math; | |
| 15 | 16 | pub mod text; |
| 16 | 17 | |
| 17 | 18 | pub use ink::{Ink, InkStroke}; |
| 19 | pub use math::Math; | |
| 18 | 20 | pub(crate) mod write; |
| 19 | 21 | pub use text::Paragraph; |
| 20 | 22 |
crates/onestore/tests/page_math.rs created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | use onestore::{ | |
| 2 | RevisionIndex, Store, | |
| 3 | document::Document, | |
| 4 | page::{Math, Page, PageObject}, | |
| 5 | }; | |
| 6 | ||
| 7 | /// Three equations authored through OneNote 2010's equation editor, whose COM read exports | |
| 8 | /// MathML (`corpus/m6/native-math-01`). | |
| 9 | const NATIVE: &[u8] = include_bytes!("../../../corpus/m6/native-math-01/notebook/synthetic.one"); | |
| 10 | const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-math-01/read/page-000.xml"); | |
| 11 | ||
| 12 | fn native_mathml() -> Vec<String> { | |
| 13 | NATIVE_READ | |
| 14 | .split("<mml:math") | |
| 15 | .skip(1) | |
| 16 | .map(|rest| { | |
| 17 | let body = &rest[rest.find('>').unwrap() + 1..rest.find("</mml:math>").unwrap()]; | |
| 18 | let mut decoded = String::new(); | |
| 19 | let mut remainder = body; | |
| 20 | while let Some(start) = remainder.find("&#") { | |
| 21 | decoded.push_str(&remainder[..start]); | |
| 22 | let end = start + remainder[start..].find(';').unwrap(); | |
| 23 | let code: u32 = remainder[start + 2..end].parse().unwrap(); | |
| 24 | decoded.push(char::from_u32(code).unwrap()); | |
| 25 | remainder = &remainder[end + 1..]; | |
| 26 | } | |
| 27 | decoded.push_str(remainder); | |
| 28 | decoded | |
| 29 | }) | |
| 30 | .collect() | |
| 31 | } | |
| 32 | ||
| 33 | #[test] | |
| 34 | fn native_equations_render_to_the_mathml_onenote_exports() { | |
| 35 | let store = Store::parse(NATIVE).unwrap(); | |
| 36 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 37 | let document = Document::parse(&index).unwrap(); | |
| 38 | let page = document | |
| 39 | .pages() | |
| 40 | .unwrap() | |
| 41 | .into_iter() | |
| 42 | .map(|(space, _)| Page::from_space(&document, space).unwrap()) | |
| 43 | .find(|page| page.title == "Equation controls") | |
| 44 | .unwrap(); | |
| 45 | let mut rendered = Vec::new(); | |
| 46 | for object in &page.objects { | |
| 47 | let PageObject::Outline(outline) = object else { | |
| 48 | continue; | |
| 49 | }; | |
| 50 | for paragraph in &outline.paragraphs { | |
| 51 | let Some(text) = paragraph.text() else { | |
| 52 | continue; | |
| 53 | }; | |
| 54 | if !Math::is_equation(&text.text) { | |
| 55 | continue; | |
| 56 | } | |
| 57 | // Inline math follows ordinary text in the same paragraph; only the math runs | |
| 58 | // form the equation. | |
| 59 | let mut at = 0; | |
| 60 | let math_runs: Vec<(String, onestore::document::Format)> = text | |
| 61 | .text | |
| 62 | .spans() | |
| 63 | .iter() | |
| 64 | .map(|span| { | |
| 65 | let piece = text.text.text()[at..span.end].to_owned(); | |
| 66 | at = span.end; | |
| 67 | (piece, span.format.clone()) | |
| 68 | }) | |
| 69 | .filter(|(_, format)| format.math == Some(true)) | |
| 70 | .collect(); | |
| 71 | let equation = onestore::page::Paragraph::from_runs(math_runs); | |
| 72 | rendered.push(Math::mathml(&Math::parse(&equation).unwrap())); | |
| 73 | } | |
| 74 | } | |
| 75 | assert_eq!(rendered, native_mathml()); | |
| 76 | } |