Copied equations reach other programs as MathML (#816) (#1147)

* #816: copied equations reach other programs as MathML

A copy or cut whose selection holds an equation now also puts HTML on the
system clipboard, with each equation as Presentation MathML (wrapped in
<semantics> with the linear format as an application/x-unicodemath
annotation), so Word, LibreOffice, Google Docs and browsers paste a
formatted equation instead of `y=2x_s`.

- formats: new mathml module, a writer from the equation tree (runs,
  fractions, scripts and pre-scripts, radicals, n-ary operators, delimiters,
  functions, limits, accents, bars, border boxes, group characters, equation
  arrays, matrices, phantoms) and a bounded reader (our annotation, else the
  presentation elements, else a TeX annotation). HTML export writes
  equations as MathML; HTML import (open and paste) reads <math> back.
- engine: edit.copy/edit.cut (and column copy) return `html` and keep
  Session::clipboard_html when the fragment holds an equation.
- ui-egui: Services::set_clipboard_html; copy sites put HTML with the text
  through it, falling back to egui's text copy.
- wordcraft: arboard set().html() with the text alternative; on Wayland
  without data-control the text-only path is kept.

OMML on the clipboard (Word's "Office Open XML") needs custom clipboard
formats arboard can't set; left as a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #816: honest clipboard docs; MathML reader fixes (token cap, function names, column vectors)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-14 02:04:46 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent e8482fa964
commit aa113f0801
19 changed files with 1200 additions and 25 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: absolute interface sizes, #822; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: equations copy as MathML, #816; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -146,6 +146,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) |
| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged |
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
+51
View File
@@ -8,6 +8,11 @@
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
//!
//! Everything read here comes from other programs: sizes are capped as it is read.
//!
//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text
//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML"
//! with OMML, #1297) it can't, so they aren't offered.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
@@ -16,6 +21,35 @@ fn open() -> Option<arboard::Clipboard> {
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
}
thread_local! {
/// The clipboard copies are set through, kept open: on X11 the contents are served from it.
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative. `false` when it couldn't
/// (the caller copies the text alone), including a Wayland session without the data-control
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
pub fn set_html(html: &str, text: &str) -> bool {
if !platform::can_set() {
return false;
}
WRITER.with(|w| {
let Ok(mut slot) = w.try_borrow_mut() else { return false };
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
*slot = None;
false
}
None => false,
}
})
}
/// The clipboard's text.
pub fn text() -> Option<String> {
open()?.get_text().ok()
@@ -103,6 +137,15 @@ mod platform {
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
}
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
pub fn can_set() -> bool {
!wayland()
|| !matches!(
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified),
Err(wl_clipboard_rs::paste::Error::MissingProtocol { .. } | wl_clipboard_rs::paste::Error::WaylandConnection(_))
)
}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
@@ -146,6 +189,10 @@ mod platform {
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
}
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
@@ -161,4 +208,8 @@ mod platform {
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
}
+1
View File
@@ -212,6 +212,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
set_clipboard_html: Some(Box::new(clipboard::set_html)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
..Default::default()
+15 -4
View File
@@ -23,16 +23,27 @@ pub struct Fragment {
impl Fragment {
pub fn plain_text(&self) -> String {
self.to_document().plain_text(crate::StoryRef::Body)
}
/// The fragment as the body of a new document (default styles).
pub fn to_document(&self) -> Document {
let mut d = Document::new();
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d.plain_text(crate::StoryRef::Body)
d
}
/// Whether the fragment holds an equation (in tables too), which a copy also puts on the
/// system clipboard as MathML (#816).
pub fn has_equation(&self) -> bool {
let mut found = false;
for b in &self.blocks {
each_para(b, 0, &mut |p| found |= p.objects.iter().any(|o| matches!(o, InlineObject::Equation { .. })));
}
found
}
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
/// (Wingdings…) as their Unicode equivalents.
pub fn clipboard_text(&self) -> String {
let mut d = Document::new();
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
d.shown_text(crate::StoryRef::Body)
self.to_document().shown_text(crate::StoryRef::Body)
}
pub fn from_text(s: &str) -> Fragment {
let lines: Vec<&str> = s.split('\n').collect();
+1 -1
View File
@@ -211,7 +211,7 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
}
s.clipboard_text = rows.join("\n");
s.clipboard = Some(Fragment { blocks, parts });
Ok(json!({"text": s.clipboard_text}))
Ok(super::edit::copy_result(s))
}
/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at
+11 -1
View File
@@ -162,7 +162,17 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(json!({"text": s.clipboard_text}))
Ok(copy_result(s))
}
/// What Copy and Cut return: the clipboard's text, and HTML with the equations as MathML when
/// the copy holds any (#816), for the host to put on the system clipboard too.
pub(crate) fn copy_result(s: &mut Session) -> Value {
s.clipboard_html = match &s.clipboard {
Some(f) if f.has_equation() => wordcraft_formats::html::export_fragment(&f.to_document()),
_ => String::new(),
};
if s.clipboard_html.is_empty() { json!({"text": s.clipboard_text}) } else { json!({"text": s.clipboard_text, "html": s.clipboard_html}) }
}
fn cut(s: &mut Session, v: &Value) -> CmdResult {
+5
View File
@@ -338,6 +338,9 @@ pub struct Session {
pub pending_move: Option<crate::cmd::moves::PendingMove>,
/// Plain text mirror of the clipboard (for the system clipboard).
pub clipboard_text: String,
/// HTML for the system clipboard beside [`Self::clipboard_text`] when the copy holds an
/// equation (as MathML, #816), for programs that read MathML; empty otherwise.
pub clipboard_html: String,
/// Items collected by Copy and Cut this session, for the Clipboard pane.
pub clip_history: crate::cmd::edit::ClipHistory,
pub find: FindState,
@@ -574,6 +577,7 @@ impl Session {
clipboard: None,
pending_move: None,
clipboard_text: String::new(),
clipboard_html: String::new(),
clip_history: Default::default(),
find: FindState::default(),
goal_x: None,
@@ -1005,6 +1009,7 @@ impl Session {
// Like the clipboard: what the next paste can make a move of.
pending_move: _,
clipboard_text: _,
clipboard_html: _,
clip_history: _,
find: _,
goal_x: _,
+49
View File
@@ -813,6 +813,55 @@ fn clipboard_round_trip() {
assert_eq!(text(&s), "plain\ntext");
}
/// Equations in the paragraphs of a session's body, as linear text.
fn equations(s: &Session) -> Vec<String> {
let mut out = Vec::new();
for b in &s.doc.body {
if let wordcraft_doc::Block::Para(p) = &**b {
for o in &p.objects {
if let wordcraft_doc::para::InlineObject::Equation { linear, .. } = o {
out.push(linear.clone());
}
}
}
}
out
}
#[test]
fn copying_an_equation_puts_mathml_on_the_clipboard() {
// #816: other programs get the equation as MathML in HTML, beside the linear text.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Speed "}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.equation", json!({"linear": "y=2x_s", "edit": false}));
run(&mut s, "select.all", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
let html = r["html"].as_str().unwrap_or_default().to_string();
assert!(html.contains("<math xmlns=\"http://www.w3.org/1998/Math/MathML\">"), "{html}");
assert!(html.contains("<msub><mrow><mi>x</mi></mrow><mrow><mi>s</mi></mrow></msub>"), "{html}");
assert!(html.contains("Speed"), "{html}");
assert_eq!(s.clipboard_html, html);
assert!(r["text"].as_str().unwrap_or_default().contains("y=2x_s"));
let clip = r["text"].as_str().unwrap_or_default().to_string();
// Pasting it into another document reads the equation back from the HTML.
let mut t = Session::new(wordcraft_doc::Document::new());
run(&mut t, "edit.paste", json!({"text": clip, "html": html}));
assert_eq!(equations(&t), vec!["y=2x_s".to_string()]);
assert!(text(&t).starts_with("Speed"), "{}", text(&t));
// And back into this one, from our own copy.
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.paste", json!({"text": clip, "html": html}));
assert_eq!(equations(&s), vec!["y=2x_s".to_string(), "y=2x_s".to_string()]);
// A copy without an equation carries no HTML.
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}}));
let r = run(&mut s, "edit.copy", json!({}));
assert!(r.get("html").is_none() && s.clipboard_html.is_empty(), "{r}");
}
#[test]
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
let mut s = s();
+100 -2
View File
@@ -1125,6 +1125,15 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
Tok::Text(t) => b.text(&t),
Tok::Start { name, attrs, self_close } => {
b.close_head_for_start(&name);
if crate::mathml::local(&name) == "math" && !self_close {
let el = read_math(&mut lx, name, attrs);
if !b.in_head()
&& let Some((linear, display)) = crate::mathml::read(&el)
{
b.start_para().inlines.push(Inline::Equation { linear, display });
}
continue;
}
if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close {
lx.skip_past(&format!("</{name}"));
lx.skip_past(">");
@@ -1138,6 +1147,86 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
b.finish()
}
/// A `<math>` element (#816): its tokens up to the matching end tag, as a tree. Nesting and size
/// are bounded; an unclosed element ends with the input.
fn read_math(lx: &mut Lexer, name: String, attrs: Vec<(String, String)>) -> crate::mathml::El {
use crate::mathml::{El, Item, MAX_NODES, local};
let mut stack: Vec<El> = vec![El { name, attrs, children: Vec::new() }];
let mut count = 0usize;
let close = |stack: &mut Vec<El>| {
if stack.len() > 1
&& let Some(done) = stack.pop()
&& let Some(parent) = stack.last_mut()
{
parent.children.push(Item::El(done));
}
};
while let Some(t) = lx.next() {
count += 1;
if count > MAX_NODES * 4 {
// Over the cap: drop the rest of this element so its tokens aren't read as HTML text.
skip_math(lx, t);
break;
}
match t {
Tok::Text(t) => {
if let Some(top) = stack.last_mut()
&& !t.is_empty()
{
top.children.push(Item::Text(t));
}
}
Tok::Start { name, attrs, self_close } => {
let el = El { name, attrs, children: Vec::new() };
if self_close || stack.len() > wordcraft_doc::math::MAX_DEPTH {
if let Some(top) = stack.last_mut() {
top.children.push(Item::El(el));
}
} else {
stack.push(el);
}
}
Tok::End(name) => {
let name = local(&name).to_string();
// Close up to the matching open element; a stray end tag is ignored.
if let Some(k) = stack.iter().rposition(|e| local(&e.name) == name) {
if k == 0 {
break;
}
while stack.len() > k {
close(&mut stack);
}
}
}
}
}
while stack.len() > 1 {
close(&mut stack);
}
stack.pop().unwrap_or_default()
}
/// Skip tokens (starting with `first`) up to and including the `</math>` that closes the element
/// being read; nested `<math>` elements are counted. An unclosed element ends with the input.
fn skip_math(lx: &mut Lexer, first: Tok) {
use crate::mathml::local;
let mut depth = 0usize;
let mut t = Some(first);
while let Some(tok) = t {
match tok {
Tok::Start { name, self_close: false, .. } if local(&name) == "math" => depth = depth.saturating_add(1),
Tok::End(name) if local(&name) == "math" => {
if depth == 0 {
return;
}
depth = depth.saturating_sub(1);
}
_ => {}
}
t = lx.next();
}
}
/// Parse HTML into a document.
pub fn import(s: &str) -> Document {
model::to_doc(&parse(s))
@@ -1198,7 +1287,6 @@ fn text_html(t: &str) -> String {
/// Inline spans → HTML.
pub fn inlines_html(inlines: &[Inline]) -> String {
let mut o = String::new();
let inlines = crate::model::equations_as_text(inlines);
for i in inlines.iter() {
match i {
Inline::Text(t, f) => {
@@ -1269,7 +1357,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
Inline::Anchor(a) => o.push_str(&format!("<a id=\"{}\"></a>", esc(a))),
// No picture to show: the browser shows the alt text in its place.
Inline::Figure(alt) => o.push_str(&format!("<img alt=\"{}\">", esc(alt))),
Inline::Equation { .. } => {}
// MathML, which browsers, Word and LibreOffice show as an equation (#816).
Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)),
}
}
o
@@ -1448,6 +1537,15 @@ pub fn export(doc: &Document) -> String {
export_flow(&flow, doc.styles.default_chr.lang.as_deref().unwrap_or("en"))
}
/// `doc`'s content as an HTML fragment (no head or style sheet): what a copy puts on the system
/// clipboard beside its text, equations as MathML (#816).
pub fn export_fragment(doc: &Document) -> String {
let flow = model::from_doc(doc);
let mut out = String::from("<meta charset=\"utf-8\">");
blocks_html(&flow.blocks, &mut out, 0);
out
}
pub fn export_flow(flow: &Flow, lang: &str) -> String {
let m = &flow.meta;
let mut out = String::new();
+1
View File
@@ -13,6 +13,7 @@ pub mod comments;
pub mod html;
pub mod latex;
pub mod markdown;
pub mod mathml;
pub mod model;
pub mod odt;
pub mod rtf;
+895
View File
@@ -0,0 +1,895 @@
//! Equations as MathML (W3C MathML Core / Presentation MathML): written into HTML export and the
//! HTML a copy puts on the system clipboard, so programs that read MathML can get a formatted
//! equation instead of its linear text (#816; pasting into Word and LibreOffice not yet verified;
//! OMML for Word is #1297); and read back from pasted or opened HTML.
//!
//! The writer walks the equation tree ([`MNode`]) and wraps it in `<semantics>` with the linear
//! format as an `application/x-unicodemath` annotation, which renderers ignore and WordCraft reads
//! back exactly. The reader takes that annotation when present, else a TeX annotation, else
//! builds the tree from the presentation elements (fractions, scripts, radicals, under/over
//! limits, tables, fences); both bound their nesting and size.
use wordcraft_doc::math::{
Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, is_nary_char, math_alnum, math_class,
to_linear,
};
use wordcraft_doc::math_linear::FUNCTION_NAMES;
use crate::html::esc;
/// The MathML namespace.
pub const NS: &str = "http://www.w3.org/1998/Math/MathML";
/// The annotation encoding carrying the linear format (UnicodeMath).
pub const UNICODEMATH: &str = "application/x-unicodemath";
// ---------------------------------------------------------------------------------------------
// Writer
/// An equation as a `<math>` element. `linear` (the linear format) goes in an annotation for an
/// exact round trip; empty = taken from the tree.
pub fn to_mathml(nodes: &[MNode], display: bool, linear: &str) -> String {
let mut o = String::new();
o.push_str(&format!("<math xmlns=\"{NS}\""));
if display {
o.push_str(" display=\"block\"");
}
o.push_str("><semantics><mrow>");
arg(nodes, &mut o, 0, false);
o.push_str("</mrow>");
let linear = if linear.is_empty() { to_linear(nodes) } else { linear.to_string() };
if !linear.is_empty() {
o.push_str(&format!("<annotation encoding=\"{UNICODEMATH}\">{}</annotation>", esc(&linear)));
}
o.push_str("</semantics></math>");
o
}
/// An equation known by its linear format, as a `<math>` element.
pub fn linear_to_mathml(linear: &str, display: bool) -> String {
to_mathml(&wordcraft_doc::math::parse_linear(linear), display, linear)
}
/// An argument as a row of elements. `eq_arr`: a row of an equation array, whose `&` marks are
/// alignment points, not text.
fn arg(a: &[MNode], o: &mut String, depth: usize, eq_arr: bool) {
if depth > MAX_DEPTH {
return;
}
for n in a {
node(n, o, depth + 1, eq_arr);
}
}
/// An argument as one element (`<mrow>` around several).
fn group(a: &[MNode], o: &mut String, depth: usize) {
o.push_str("<mrow>");
arg(a, o, depth, false);
o.push_str("</mrow>");
}
fn mo(c: char, attrs: &str, o: &mut String) {
o.push_str(&format!("<mo{attrs}>{}</mo>", esc(&c.to_string())));
}
fn run(r: &MRun, o: &mut String, eq_arr: bool) {
let color = r.color.map(|c| format!(" mathcolor=\"#{}\"", c.hex())).unwrap_or_default();
if r.nor {
if !r.text.is_empty() {
o.push_str(&format!("<mtext{color}>{}</mtext>", esc(&r.text)));
}
return;
}
let bold = matches!(r.sty, Some(MSty::Bold | MSty::BoldItalic));
let italic = match r.sty {
None => r.scr == MScr::Roman,
Some(s) => matches!(s, MSty::Italic | MSty::BoldItalic),
};
let cs: Vec<char> = r.text.chars().collect();
let mut i = 0;
while let Some(&c) = cs.get(i) {
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
let mut num = String::new();
while let Some(&d) =
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit()))
{
num.push(d);
i += 1;
}
let num: String = if bold { num.chars().map(|d| math_alnum(d, true, false, MScr::Roman)).collect() } else { num };
o.push_str(&format!("<mn{color}>{}</mn>", esc(&num)));
continue;
}
i += 1;
if c.is_whitespace() || (eq_arr && c == '&') {
continue;
}
if c.is_alphabetic() {
if bold || r.scr != MScr::Roman {
let m = math_alnum(c, bold, italic, r.scr);
let plain = if m == c && !italic { " mathvariant=\"normal\"" } else { "" };
o.push_str(&format!("<mi{plain}{color}>{}</mi>", esc(&m.to_string())));
} else if italic {
o.push_str(&format!("<mi{color}>{}</mi>", esc(&c.to_string())));
} else {
o.push_str(&format!("<mi mathvariant=\"normal\"{color}>{}</mi>", esc(&c.to_string())));
}
} else if math_class(c) == MClass::Ord && !c.is_ascii_punctuation() && c != '\u{2061}' {
// ∞, ∂, ∅, ℏ…: identifiers, not operators.
o.push_str(&format!("<mi{color}>{}</mi>", esc(&c.to_string())));
} else {
mo(c, &color, o);
}
}
}
/// A function name (`sin`, `lim`): one upright identifier when it's plain text.
fn func_name(name: &[MNode], o: &mut String, depth: usize) {
match name {
[MNode::Run(r)] if !r.nor && r.text.chars().all(char::is_alphabetic) && !r.text.is_empty() => {
o.push_str(&format!("<mi>{}</mi>", esc(&r.text)));
}
_ => group(name, o, depth),
}
}
fn node(n: &MNode, o: &mut String, depth: usize, eq_arr: bool) {
match n {
MNode::Run(r) => run(r, o, eq_arr),
MNode::Frac { kind, num, den } => match kind {
FracKind::Bar | FracKind::NoBar => {
o.push_str(if *kind == FracKind::NoBar { "<mfrac linethickness=\"0\">" } else { "<mfrac>" });
group(num, o, depth);
group(den, o, depth);
o.push_str("</mfrac>");
}
FracKind::Skewed | FracKind::Linear => {
o.push_str("<mrow>");
group(num, o, depth);
mo(if *kind == FracKind::Skewed { '⁄' } else { '/' }, "", o);
group(den, o, depth);
o.push_str("</mrow>");
}
},
MNode::Script { kind, base, sub, sup } => match kind {
ScriptKind::Sup => {
o.push_str("<msup>");
group(base, o, depth);
group(sup, o, depth);
o.push_str("</msup>");
}
ScriptKind::Sub => {
o.push_str("<msub>");
group(base, o, depth);
group(sub, o, depth);
o.push_str("</msub>");
}
ScriptKind::SubSup => {
o.push_str("<msubsup>");
group(base, o, depth);
group(sub, o, depth);
group(sup, o, depth);
o.push_str("</msubsup>");
}
ScriptKind::Pre => {
o.push_str("<mmultiscripts>");
group(base, o, depth);
o.push_str("<mprescripts/>");
group(sub, o, depth);
group(sup, o, depth);
o.push_str("</mmultiscripts>");
}
},
MNode::Rad { deg, deg_hide, e } => {
if *deg_hide || deg.is_empty() {
o.push_str("<msqrt>");
arg(e, o, depth, false);
o.push_str("</msqrt>");
} else {
o.push_str("<mroot>");
group(e, o, depth);
group(deg, o, depth);
o.push_str("</mroot>");
}
}
MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => {
let under_over = match lim_loc {
Some(l) => *l == LimLoc::UndOvr,
None => !is_integral(*chr),
};
let has_sub = !*sub_hide && !sub.is_empty();
let has_sup = !*sup_hide && !sup.is_empty();
let tag = match (under_over, has_sub, has_sup) {
(_, false, false) => "",
(true, true, true) => "munderover",
(true, true, false) => "munder",
(true, false, true) => "mover",
(false, true, true) => "msubsup",
(false, true, false) => "msub",
(false, false, true) => "msup",
};
o.push_str("<mrow>");
if !tag.is_empty() {
o.push_str(&format!("<{tag}>"));
}
mo(*chr, " largeop=\"true\" movablelimits=\"false\"", o);
if has_sub {
group(sub, o, depth);
}
if has_sup {
group(sup, o, depth);
}
if !tag.is_empty() {
o.push_str(&format!("</{tag}>"));
}
group(e, o, depth);
o.push_str("</mrow>");
}
MNode::Delim { beg, end, sep, grow, elems, .. } => {
let stretchy = if *grow { "" } else { " stretchy=\"false\"" };
o.push_str("<mrow>");
if let Some(b) = beg {
mo(*b, &format!(" fence=\"true\" form=\"prefix\"{stretchy}"), o);
}
for (i, e) in elems.iter().enumerate() {
if i > 0 {
mo(sep.unwrap_or('|'), &format!(" separator=\"true\"{stretchy}"), o);
}
group(e, o, depth);
}
if let Some(c) = end {
mo(*c, &format!(" fence=\"true\" form=\"postfix\"{stretchy}"), o);
}
o.push_str("</mrow>");
}
MNode::Func { name, e } => {
o.push_str("<mrow>");
func_name(name, o, depth);
mo('\u{2061}', "", o);
group(e, o, depth);
o.push_str("</mrow>");
}
MNode::Lim { upper, e, lim } => {
let tag = if *upper { "mover" } else { "munder" };
o.push_str(&format!("<{tag}>"));
func_name(e, o, depth);
group(lim, o, depth);
o.push_str(&format!("</{tag}>"));
}
MNode::Acc { chr, e } => {
o.push_str("<mover accent=\"true\">");
group(e, o, depth);
mo(spacing_accent(*chr), "", o);
o.push_str("</mover>");
}
MNode::Bar { top, e } => {
if *top {
o.push_str("<mover accent=\"true\">");
group(e, o, depth);
mo('\u{203E}', "", o);
o.push_str("</mover>");
} else {
o.push_str("<munder accentunder=\"true\">");
group(e, o, depth);
mo('_', "", o);
o.push_str("</munder>");
}
}
MNode::BorderBox { hide, strike, e } => {
let mut notation: Vec<&str> = Vec::new();
for (h, name) in hide.iter().zip(["top", "bottom", "left", "right"]) {
if !h {
notation.push(name);
}
}
if notation.len() == 4 {
notation = vec!["box"];
}
for (s, name) in strike.iter().zip(["horizontalstrike", "verticalstrike", "updiagonalstrike", "downdiagonalstrike"]) {
if *s {
notation.push(name);
}
}
o.push_str(&format!("<menclose notation=\"{}\">", notation.join(" ")));
arg(e, o, depth, false);
o.push_str("</menclose>");
}
MNode::Boxed { e } => group(e, o, depth),
MNode::GroupChr { chr, top, e } => {
let tag = if *top { "mover" } else { "munder" };
o.push_str(&format!("<{tag}>"));
group(e, o, depth);
mo(*chr, " stretchy=\"true\"", o);
o.push_str(&format!("</{tag}>"));
}
MNode::EqArr { rows } => {
// `displaystyle` marks an equation array; a bare one-column table reads as a matrix.
o.push_str("<mtable displaystyle=\"true\">");
for r in rows {
o.push_str("<mtr><mtd>");
arg(r, o, depth, true);
o.push_str("</mtd></mtr>");
}
o.push_str("</mtable>");
}
MNode::Matrix { rows, col_jc } => {
o.push_str("<mtable>");
for r in rows {
o.push_str("<mtr>");
for (k, cell) in r.iter().enumerate() {
match col_jc.get(k) {
Some(ColJc::Left) => o.push_str("<mtd columnalign=\"left\">"),
Some(ColJc::Right) => o.push_str("<mtd columnalign=\"right\">"),
_ => o.push_str("<mtd>"),
}
arg(cell, o, depth, false);
o.push_str("</mtd>");
}
o.push_str("</mtr>");
}
o.push_str("</mtable>");
}
MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => {
let mut pad = String::new();
for (z, a) in [(zero_wid, "width"), (zero_asc, "height"), (zero_desc, "depth")] {
if *z {
pad.push_str(&format!(" {a}=\"0\""));
}
}
if !pad.is_empty() {
o.push_str(&format!("<mpadded{pad}>"));
}
o.push_str(if *show { "<mrow>" } else { "<mphantom>" });
arg(e, o, depth, false);
o.push_str(if *show { "</mrow>" } else { "</mphantom>" });
if !pad.is_empty() {
o.push_str("</mpadded>");
}
}
}
}
/// Combining accents (how equations keep them) and the spacing characters MathML puts over the
/// base, both ways.
const ACCENTS: &[(char, char)] = &[
('\u{0302}', '^'),
('\u{0303}', '~'),
('\u{0304}', '¯'),
('\u{0305}', '\u{203E}'),
('\u{0306}', '˘'),
('\u{0307}', '˙'),
('\u{0308}', '¨'),
('\u{030C}', 'ˇ'),
('\u{0301}', '´'),
('\u{0300}', '`'),
('\u{030A}', '˚'),
('\u{20D7}', '→'),
('\u{20D6}', '←'),
('\u{20E1}', '↔'),
('\u{20D1}', '⇀'),
('\u{20D0}', '↼'),
];
fn spacing_accent(c: char) -> char {
ACCENTS.iter().find(|(comb, _)| *comb == c).map(|(_, s)| *s).unwrap_or(c)
}
fn combining_accent(c: char) -> Option<char> {
let c = match c {
'ˆ' => '^',
'˜' => '~',
'‾' => '\u{203E}',
'\u{20D7}' | '\u{0302}' | '\u{0303}' | '\u{0307}' | '\u{0308}' => return Some(c),
c => c,
};
ACCENTS.iter().find(|(_, s)| *s == c).map(|(comb, _)| *comb)
}
// ---------------------------------------------------------------------------------------------
// Reader
/// Most elements read from one `<math>` element.
pub const MAX_NODES: usize = 20_000;
/// A parsed MathML element (namespace prefixes dropped, names lowercase).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct El {
pub name: String,
pub attrs: Vec<(String, String)>,
pub children: Vec<Item>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Item {
El(El),
Text(String),
}
impl El {
fn attr(&self, name: &str) -> Option<&str> {
self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
}
fn els(&self) -> impl Iterator<Item = &El> {
self.children.iter().filter_map(|c| match c {
Item::El(e) => Some(e),
Item::Text(_) => None,
})
}
/// The text inside, all descendants (depth-limited).
pub fn text(&self) -> String {
let mut s = String::new();
text_into(self, &mut s, 0);
s
}
}
fn text_into(e: &El, s: &mut String, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in &e.children {
match c {
Item::Text(t) => s.push_str(t),
Item::El(c) => text_into(c, s, depth + 1),
}
}
}
/// The element name without a namespace prefix (`m:math`, `mml:mfrac`).
pub fn local(name: &str) -> &str {
name.rsplit(':').next().unwrap_or(name)
}
/// The linear format of a `<math>` element, and whether it's a display equation. `None` when it
/// holds nothing.
pub fn read(math: &El) -> Option<(String, bool)> {
let display = math.attr("display").is_some_and(|d| d.eq_ignore_ascii_case("block")) || math.attr("mode").is_some_and(|d| d == "display");
let linear = linear_of(math);
let linear: String = linear.chars().filter(|c| !c.is_control()).collect();
let linear = linear.trim().to_string();
(!linear.is_empty()).then_some((linear, display))
}
fn linear_of(math: &El) -> String {
// Our own copy carries the linear format exactly.
if let Some(sem) = math.els().find(|e| local(&e.name) == "semantics") {
let ann = |enc: &dyn Fn(&str) -> bool| {
sem.els()
.filter(|e| local(&e.name) == "annotation")
.find(|e| e.attr("encoding").is_some_and(|v| enc(&v.to_ascii_lowercase())))
.map(El::text)
.filter(|t| !t.trim().is_empty())
};
if let Some(t) = ann(&|v| v == UNICODEMATH) {
return t;
}
let tree = to_tree(math);
if !tree.is_empty() {
return to_linear(&tree);
}
if let Some(t) = ann(&|v| v.contains("tex")) {
return to_linear(&wordcraft_doc::math_latex::parse_latex(&t));
}
return String::new();
}
to_linear(&to_tree(math))
}
/// A MathML element's children as an equation tree.
pub fn to_tree(math: &El) -> Arg {
let mut budget = MAX_NODES;
wordcraft_doc::math::merge_runs(children(math, 0, &mut budget))
}
fn children(e: &El, depth: usize, budget: &mut usize) -> Arg {
let els: Vec<&El> = e.els().collect();
seq(&els, depth, budget)
}
/// A sequence of elements; an n-ary operator without an operand takes the next element.
fn seq(els: &[&El], depth: usize, budget: &mut usize) -> Arg {
let mut out: Arg = Vec::new();
let mut i = 0;
while let Some(el) = els.get(i) {
i += 1;
// A function name (`<mi>sin</mi>`) applies to the next element even without U+2061,
// so it doesn't merge with it into `sinx`. Before an operator (`(`, U+2061) the runs
// merge and the linear format finds the function itself.
if local(&el.name) == "mi"
&& let Some(next) = els.get(i).filter(|n| local(&n.name) != "mo")
{
let t = el.text();
let t = t.trim();
if FUNCTION_NAMES.contains(&t) && *budget > 0 {
*budget -= 1;
i += 1;
out.push(MNode::Func { name: vec![MNode::Run(MRun::plain(t))], e: elem(next, depth + 1, budget) });
continue;
}
}
let mut a = elem(el, depth, budget);
if let [MNode::Nary { e, .. }] = a.as_mut_slice()
&& e.is_empty()
&& let Some(next) = els.get(i)
{
i += 1;
*e = elem(next, depth, budget);
}
out.extend(a);
}
out
}
/// The `k`th child element as an argument.
fn nth(e: &El, k: usize, depth: usize, budget: &mut usize) -> Arg {
e.els().nth(k).map(|c| elem(c, depth, budget)).unwrap_or_default()
}
/// A lone operator character (`<mo>∑</mo>`).
fn lone_char(a: &[MNode]) -> Option<char> {
match a {
[MNode::Run(r)] => {
let mut cs = r.text.trim().chars();
let c = cs.next()?;
cs.next().is_none().then_some(c)
}
_ => None,
}
}
/// A plain word (`lim`, `max`).
fn word(a: &[MNode]) -> bool {
matches!(a, [MNode::Run(r)] if r.text.chars().count() > 1 && r.text.chars().all(char::is_alphabetic))
}
fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg {
if depth > MAX_DEPTH || *budget == 0 {
return Vec::new();
}
*budget -= 1;
let d = depth + 1;
match local(&e.name) {
"mi" | "mn" | "mo" | "ms" => {
let t = e.text();
let t = t.trim();
if t.is_empty() {
return Vec::new();
}
let mut r = MRun::new(t);
if local(&e.name) == "mi" && e.attr("mathvariant") == Some("normal") && t.chars().count() == 1 {
r.sty = Some(MSty::Plain);
}
vec![MNode::Run(r)]
}
"mtext" => {
let t = e.text();
if t.trim().is_empty() {
return Vec::new();
}
vec![MNode::Run(MRun { text: t, nor: true, ..Default::default() })]
}
"mfrac" => {
let thin = e.attr("linethickness").is_some_and(|t| t.trim().trim_end_matches(|c: char| c.is_alphabetic()).trim() == "0");
let kind = if e.attr("bevelled") == Some("true") {
FracKind::Skewed
} else if thin {
FracKind::NoBar
} else {
FracKind::Bar
};
vec![MNode::Frac { kind, num: nth(e, 0, d, budget), den: nth(e, 1, d, budget) }]
}
"msqrt" => vec![MNode::Rad { deg: Vec::new(), deg_hide: true, e: children(e, d, budget) }],
"mroot" => vec![MNode::Rad { deg: nth(e, 1, d, budget), deg_hide: false, e: nth(e, 0, d, budget) }],
"msup" | "msub" | "msubsup" => {
let name = local(&e.name);
let base = nth(e, 0, d, budget);
let (sub, sup) = match name {
"msup" => (Vec::new(), nth(e, 1, d, budget)),
"msub" => (nth(e, 1, d, budget), Vec::new()),
_ => (nth(e, 1, d, budget), nth(e, 2, d, budget)),
};
if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) {
return vec![nary(c, LimLoc::SubSup, sub, sup)];
}
let kind = match name {
"msup" => ScriptKind::Sup,
"msub" => ScriptKind::Sub,
_ => ScriptKind::SubSup,
};
vec![MNode::Script { kind, base, sub, sup }]
}
"munder" | "mover" | "munderover" => {
let name = local(&e.name);
let base = nth(e, 0, d, budget);
let (under, over) = match name {
"munder" => (nth(e, 1, d, budget), Vec::new()),
"mover" => (Vec::new(), nth(e, 1, d, budget)),
_ => (nth(e, 1, d, budget), nth(e, 2, d, budget)),
};
if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) {
return vec![nary(c, LimLoc::UndOvr, under, over)];
}
if name == "munderover" {
let inner = MNode::Lim { upper: false, e: base, lim: under };
return vec![MNode::Lim { upper: true, e: vec![inner], lim: over }];
}
let upper = name == "mover";
let mark = if upper { lone_char(&over) } else { lone_char(&under) };
match mark {
Some('\u{203E}' | '¯' | '‾') if upper => vec![MNode::Bar { top: true, e: base }],
Some('_' | '\u{203E}' | '¯') if !upper => vec![MNode::Bar { top: false, e: base }],
Some(c @ ('⏞' | '⏟' | '⏜' | '⏝' | '⎴' | '⎵' | '←' | '→' | '↔' | '⇐' | '⇒' | '⇔')) if !word(&base) => {
vec![MNode::GroupChr { chr: c, top: upper, e: base }]
}
Some(c) if upper && combining_accent(c).is_some() => vec![MNode::Acc { chr: combining_accent(c).unwrap_or(c), e: base }],
_ => vec![MNode::Lim { upper, e: base, lim: if upper { over } else { under } }],
}
}
"mmultiscripts" => {
let els: Vec<&El> = e.els().collect();
let base = els.first().map(|b| elem(b, d, budget)).unwrap_or_default();
let pre_at = els.iter().position(|c| local(&c.name) == "mprescripts");
let script =
|k: usize, budget: &mut usize| els.get(k).filter(|c| local(&c.name) != "none").map(|c| elem(c, d, budget)).unwrap_or_default();
match pre_at {
Some(p) => {
let sub = script(p + 1, budget);
let sup = script(p + 2, budget);
vec![MNode::Script { kind: ScriptKind::Pre, base, sub, sup }]
}
None => {
let sub = script(1, budget);
let sup = script(2, budget);
let kind = match (sub.is_empty(), sup.is_empty()) {
(true, true) => return base,
(false, true) => ScriptKind::Sub,
(true, false) => ScriptKind::Sup,
(false, false) => ScriptKind::SubSup,
};
vec![MNode::Script { kind, base, sub, sup }]
}
}
}
"mfenced" => {
let first = |a: &str, dflt: char| e.attr(a).map(|v| v.trim().chars().next()).unwrap_or(Some(dflt));
let beg = first("open", '(');
let end = first("close", ')');
let sep = first("separators", ',');
let elems: Vec<Arg> = e.els().map(|c| elem(c, d, budget)).collect();
vec![MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }]
}
"mtable" => {
let rows: Vec<Vec<Arg>> = e
.els()
.filter(|r| matches!(local(&r.name), "mtr" | "mlabeledtr"))
.take(256)
.map(|r| r.els().filter(|c| local(&c.name) == "mtd").take(256).map(|c| children(c, d, budget)).collect())
.collect();
if rows.is_empty() {
return Vec::new();
}
// One column is an equation array only when marked as one (`displaystyle`, alignment
// marks, labelled rows, as TeX's align and our own writer give); else a column vector.
let marked =
e.attr("displaystyle").is_some_and(|v| v.trim() == "true") || e.els().any(|r| local(&r.name) == "mlabeledtr") || has_align_mark(e, 0);
if marked && rows.iter().all(|r| r.len() <= 1) {
return vec![MNode::EqArr { rows: rows.into_iter().map(|mut r| r.pop().unwrap_or_default()).collect() }];
}
vec![MNode::Matrix { rows, col_jc: Vec::new() }]
}
"mphantom" => vec![MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: children(e, d, budget) }],
"menclose" => {
let n = e.attr("notation").unwrap_or("longdiv");
let has = |k: &str| n.split_whitespace().any(|w| w == k);
if has("box") || has("roundedbox") {
return vec![MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: children(e, d, budget) }];
}
children(e, d, budget)
}
// The first child of `semantics` is the presentation; annotations aren't content.
"semantics" => e.els().next().map(|c| elem(c, d, budget)).unwrap_or_default(),
"annotation" | "annotation-xml" | "mspace" | "none" | "mprescripts" | "maligngroup" | "malignmark" => Vec::new(),
_ => children(e, d, budget),
}
}
/// Whether `e` holds a `maligngroup` or `malignmark` near the top (in a cell or a row inside it).
fn has_align_mark(e: &El, depth: usize) -> bool {
depth <= 4 && e.els().any(|c| matches!(local(&c.name), "maligngroup" | "malignmark") || has_align_mark(c, depth + 1))
}
fn nary(chr: char, lim_loc: LimLoc, sub: Arg, sup: Arg) -> MNode {
let lim_loc = Some(lim_loc).filter(|l| *l != if is_integral(chr) { LimLoc::SubSup } else { LimLoc::UndOvr });
MNode::Nary { chr, lim_loc, grow: false, sub_hide: sub.is_empty(), sup_hide: sup.is_empty(), sub, sup, e: Vec::new() }
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::math::parse_linear;
fn ml(linear: &str) -> String {
to_mathml(&parse_linear(linear), false, "")
}
#[test]
fn runs_split_into_identifiers_numbers_and_operators() {
let m = ml("y=2x");
assert!(m.starts_with("<math xmlns=\"http://www.w3.org/1998/Math/MathML\">"), "{m}");
assert!(m.contains("<mi>y</mi><mo>=</mo><mn>2</mn><mi>x</mi>"), "{m}");
assert!(m.contains("<annotation encoding=\"application/x-unicodemath\">y=2x</annotation>"), "{m}");
assert!(to_mathml(&parse_linear("x"), true, "").contains("display=\"block\""));
}
#[test]
fn structures_become_their_elements() {
for (linear, want) in [
("a/b", "<mfrac><mrow><mi>a</mi></mrow><mrow><mi>b</mi></mrow></mfrac>"),
("x^2", "<msup><mrow><mi>x</mi></mrow><mrow><mn>2</mn></mrow></msup>"),
("x_s", "<msub><mrow><mi>x</mi></mrow><mrow><mi>s</mi></mrow></msub>"),
("x_i^2", "<msubsup>"),
("√x", "<msqrt><mi>x</mi></msqrt>"),
("√(3&x)", "<mroot><mrow><mi>x</mi></mrow><mrow><mn>3</mn></mrow></mroot>"),
("∑_(i=1)^n▒i", "<munderover><mo largeop=\"true\" movablelimits=\"false\">∑</mo>"),
("∫_0^1▒x", "<msubsup><mo largeop=\"true\" movablelimits=\"false\">∫</mo>"),
("(a+b)", "<mo fence=\"true\" form=\"prefix\">(</mo>"),
("sin⁡x", "<mi>sin</mi><mo>\u{2061}</mo>"),
("■(a&b@c&d)", "<mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr>"),
] {
let m = ml(linear);
assert!(m.contains(want), "{linear}: {m}");
}
let acc = to_mathml(&[MNode::Acc { chr: '\u{0302}', e: parse_linear("a") }], false, "");
assert!(acc.contains("<mover accent=\"true\"><mrow><mi>a</mi></mrow><mo>^</mo></mover>"), "{acc}");
let bar = to_mathml(&[MNode::Bar { top: true, e: parse_linear("a") }], false, "");
assert!(bar.contains("<mo>\u{203E}</mo></mover>"), "{bar}");
let lim = to_mathml(&[MNode::Lim { upper: false, e: parse_linear("lim"), lim: parse_linear("n→∞") }], false, "");
assert!(lim.contains("<munder><mi>lim</mi><mrow><mi>n</mi><mo>→</mo><mi>∞</mi></mrow></munder>"), "{lim}");
let bb = to_mathml(&[MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: parse_linear("a") }], false, "");
assert!(bb.contains("<menclose notation=\"box\">"), "{bb}");
let gc = to_mathml(&[MNode::GroupChr { chr: '⏟', top: false, e: parse_linear("a") }], false, "");
assert!(gc.contains("<munder><mrow><mi>a</mi></mrow><mo stretchy=\"true\">⏟</mo></munder>"), "{gc}");
let pre = to_mathml(&[MNode::Script { kind: ScriptKind::Pre, base: parse_linear("C"), sub: parse_linear("n"), sup: vec![] }], false, "");
assert!(pre.contains("<mprescripts/>"), "{pre}");
let eq = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x&=1"), parse_linear("y&=2")] }], false, "");
assert!(eq.contains("<mtd><mi>x</mi><mo>=</mo><mn>1</mn></mtd>") && !eq.contains("<mo>&amp;</mo>"), "{eq}");
let ph = to_mathml(&[MNode::Phant { show: false, zero_wid: true, zero_asc: false, zero_desc: false, e: parse_linear("a") }], false, "");
assert!(ph.contains("<mpadded width=\"0\"><mphantom>"), "{ph}");
let styled = to_mathml(
&[
MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }),
MNode::Run(MRun::plain("d")),
MNode::Run(MRun { text: "speed".into(), nor: true, ..Default::default() }),
],
false,
"",
);
assert!(
styled.contains("<mi>ℝ</mi>") && styled.contains("<mi mathvariant=\"normal\">d</mi>") && styled.contains("<mtext>speed</mtext>"),
"{styled}"
);
// Text is escaped.
assert!(ml("a<b").contains("<mo>&lt;</mo>"));
}
fn pasted(html: &str) -> Vec<(String, bool)> {
let flow = crate::html::parse(html);
let mut out = Vec::new();
for b in &flow.blocks {
if let crate::model::FBlock::Para(p) = b {
for i in &p.inlines {
if let crate::model::Inline::Equation { linear, display } = i {
out.push((linear.clone(), *display));
}
}
}
}
out
}
#[test]
fn html_export_round_trips_equations() {
for (linear, display) in [("y=2x_s", false), ("x=(-b±√(b^2-4ac))/2a", true), ("∑_(k=1)^n▒k", false), ("■(a&b@c&d)", false)] {
let html = crate::html::inlines_html(&[crate::model::Inline::Equation { linear: linear.into(), display }]);
assert!(html.starts_with("<math"), "{html}");
assert_eq!(pasted(&format!("<p>Before {html} after</p>")), vec![(linear.to_string(), display)], "{html}");
}
}
#[test]
fn foreign_mathml_is_read_from_its_elements() {
let cases = [
("<math><mi>y</mi><mo>=</mo><mn>2</mn><msub><mi>x</mi><mi>s</mi></msub></math>", "y=2x_s"),
("<math display='block'><mfrac><mi>a</mi><mrow><mi>b</mi><mo>+</mo><mn>1</mn></mrow></mfrac></math>", "a/(b+1)"),
("<math><msqrt><mi>x</mi></msqrt></math>", "√x"),
("<math><munderover><mo>∑</mo><mrow><mi>k</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></munderover><mi>k</mi></math>", "∑_(k=1)^n▒k"),
("<mml:math><mml:msup><mml:mi>e</mml:mi><mml:mi>x</mml:mi></mml:msup></mml:math>", "e^x"),
// A TeX annotation is used when the presentation is empty.
("<math><semantics><mrow></mrow><annotation encoding='application/x-tex'>\\frac{1}{2}</annotation></semantics></math>", "1/2"),
];
for (html, want) in cases {
let got = pasted(html);
assert_eq!(got.first().map(|g| g.0.as_str()), Some(want), "{html}");
}
assert!(pasted("<math display=\"block\"><mi>x</mi></math>").first().is_some_and(|g| g.1));
// Text after the equation still arrives; an unclosed element ends with the input.
let f = crate::html::parse("<p><math><mi>x</mi></math> tail</p>");
assert!(crate::model::to_doc(&f).plain_text(wordcraft_doc::StoryRef::Body).contains("tail"));
assert!(!pasted("<p><math><mfrac><mi>x").is_empty());
// Hostile nesting is bounded.
let deep = format!("<math>{}<mi>x</mi>{}</math>", "<mrow>".repeat(5000), "</mrow>".repeat(5000));
assert!(pasted(&deep).len() <= 1);
}
#[test]
fn math_over_the_token_cap_is_skipped_to_its_end() {
// Past the cap, the rest of the element is dropped, not read as body text.
let html = format!("<p><math>{}<mrow><mi>z</mi></mrow></math> tail</p>", "<mi>y</mi>".repeat(MAX_NODES * 2));
let flow = crate::html::parse(&html);
let mut text = String::new();
for b in &flow.blocks {
if let crate::model::FBlock::Para(p) = b {
for i in &p.inlines {
if let crate::model::Inline::Text(t, _) = i {
text.push_str(t);
}
}
}
}
assert_eq!(text.trim(), "tail");
}
#[test]
fn function_names_apply_without_the_invisible_operator() {
let cases = [
("<math><mi>sin</mi><mi>x</mi></math>", "sin\u{2061}x"),
("<math><mi>log</mi><mrow><mi>a</mi><mi>b</mi></mrow></math>", "log\u{2061}ab"),
("<math><mi>cos</mi><mo>\u{2061}</mo><mi>x</mi></math>", "cos\u{2061}x"),
("<math><mi>ab</mi><mi>x</mi></math>", "abx"),
];
for (html, want) in cases {
assert_eq!(pasted(html).first().map(|g| g.0.as_str()), Some(want), "{html}");
}
let tree = to_tree(&El {
name: "math".into(),
attrs: vec![],
children: vec![
Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("sin".into())] }),
Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("x".into())] }),
],
});
assert!(matches!(tree.as_slice(), [MNode::Func { .. }]), "{tree:?}");
}
#[test]
fn one_column_tables_are_column_vectors_unless_marked() {
let tree = |html: &str| {
let linear = pasted(html).first().map(|g| g.0.clone()).unwrap_or_default();
wordcraft_doc::math::parse_linear(&linear)
};
let vector = tree("<math><mtable><mtr><mtd><mi>a</mi></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable></math>");
assert!(matches!(vector.as_slice(), [MNode::Matrix { rows, .. }] if rows.len() == 2), "{vector:?}");
for marked in [
"<math><mtable displaystyle='true'><mtr><mtd><mi>a</mi></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable></math>",
"<math><mtable><mtr><mtd><mi>a</mi><maligngroup/><mo>=</mo><mn>1</mn></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable></math>",
] {
let t = tree(marked);
assert!(matches!(t.as_slice(), [MNode::EqArr { rows }] if rows.len() == 2), "{marked}: {t:?}");
}
// Our own equation arrays read back as equation arrays from their elements alone.
let ml = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x=1"), parse_linear("y=2")] }], false, "");
let ml = ml.replace("application/x-unicodemath", "x-ignored");
let t = tree(&ml);
assert!(matches!(t.as_slice(), [MNode::EqArr { .. }]), "{ml}: {t:?}");
}
#[test]
fn deep_nesting_is_bounded() {
let mut n = parse_linear("x");
for _ in 0..500 {
n = vec![MNode::Frac { kind: FracKind::Bar, num: n, den: parse_linear("y") }];
}
let m = to_mathml(&n, false, "z");
assert!(m.ends_with("</math>"));
}
}
+2 -4
View File
@@ -2494,10 +2494,8 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
}
item(ui, app, "Cut", "edit.cut", json!({}));
if ui.add(egui::Button::new(tl!("Copy")).shortcut_text(crate::widgets::shortcut_text(app, "edit.copy"))).clicked() {
if let Ok(r) = app.run("edit.copy", json!({}))
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
{
ui.ctx().copy_text(t.to_string());
if let Ok(r) = app.run("edit.copy", json!({})) {
crate::system_clipboard::put_result(app, ui.ctx(), &r);
}
ui.close();
}
+2 -5
View File
@@ -315,11 +315,8 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
}
egui::Event::Copy | egui::Event::Cut => {
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
if let Ok(r) = app.run(id, json!({}))
&& let Some(t) = r.get("text").and_then(Value::as_str)
&& !t.is_empty()
{
ctx.copy_text(t.to_string());
if let Ok(r) = app.run(id, json!({})) {
crate::system_clipboard::put_result(app, ctx, &r);
}
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {
+4
View File
@@ -131,6 +131,10 @@ pub struct Services {
/// Desktop: the HTML on the system clipboard — another program's formatted copy (a Word
/// document, a web page) — if any. Paste keeps its formatting (#687); see [`system_clipboard`].
pub clipboard_html: Option<Box<dyn Fn() -> Option<String>>>,
/// Desktop: put HTML on the system clipboard with its plain-text alternative (html, text), for
/// a copy holding an equation (as MathML, #816). `true` when both are there; otherwise the
/// copy's text goes through egui as usual.
pub set_clipboard_html: Option<Box<dyn Fn(&str, &str) -> bool>>,
/// Desktop: the RTF on the system clipboard, where the platform offers it (#687).
pub clipboard_rtf: Option<Box<dyn Fn() -> Option<String>>>,
/// Desktop: the names of the formats on the system clipboard (MIME types, Windows format
+1 -1
View File
@@ -528,7 +528,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
let r = small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false);
if r.clicked() {
ui.ctx().copy_text(app.session.clipboard_text.clone());
crate::system_clipboard::put(app, ui.ctx(), &app.session.clipboard_text, &app.session.clipboard_html);
}
let active = app.session.painter.is_some();
small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), active);
+48
View File
@@ -67,6 +67,25 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
None
}
/// Put a copy on the system clipboard: its HTML beside the text when the copy has some (an
/// equation as MathML, #816) and the host can set it, else the text through egui. egui sets its
/// text at the end of the frame, which would replace the HTML, so only one of them runs.
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str) {
if text.is_empty() {
return;
}
if !html.is_empty() && app.services.set_clipboard_html.as_ref().is_some_and(|f| f(html, text)) {
return;
}
ctx.copy_text(text.to_string());
}
/// Put what Copy or Cut returned on the system clipboard (see [`put`]).
pub(crate) fn put_result(app: &WordApp, ctx: &egui::Context, r: &Value) {
let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("");
put(app, ctx, s("text"), s("html"));
}
/// Format names worth showing: not the clipboard protocol's own bookkeeping targets.
fn shown(name: &str) -> bool {
let n = name.trim();
@@ -221,4 +240,33 @@ mod tests {
let e = app(unreadable).run("edit.paste", json!({})).unwrap_err();
assert!(e.contains("couldn't be read") && e.contains("isn't supported"), "{e}");
}
#[test]
fn copying_an_equation_puts_its_html_on_the_system_clipboard() {
// #816: the host gets HTML with the equation as MathML beside the text.
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, String)>::new()));
let sink = got.clone();
let mut a = app(Services {
set_clipboard_html: Some(Box::new(move |h, t| {
sink.borrow_mut().push((h.to_string(), t.to_string()));
true
})),
..Default::default()
});
a.run("document.setText", json!({"text": "Speed "})).unwrap();
a.run("caret.docEnd", json!({})).unwrap();
a.run("insert.equation", json!({"linear": "y=2x_s", "edit": false})).unwrap();
a.run("select.all", json!({})).unwrap();
let ctx = egui::Context::default();
let r = a.run("edit.copy", json!({})).unwrap();
put_result(&a, &ctx, &r);
let got_now = got.borrow().clone();
assert_eq!(got_now.len(), 1);
assert!(got_now[0].0.contains("<math") && got_now[0].1.contains("y=2x_s"), "{got_now:?}");
// Plain text needs no HTML.
a.run("select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})).unwrap();
let r = a.run("edit.copy", json!({})).unwrap();
put_result(&a, &ctx, &r);
assert_eq!(got.borrow().len(), 1);
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: Remove personal information and Remove date and time honoured on save, #1062; previously trivial: undrawn chart types show a placeholder, #998; previously trivial: saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -28,7 +28,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL |
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) |
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) |
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) |
@@ -166,6 +166,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown |
| 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) |
| 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing |
| 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `<math>` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) |
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
| 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text |
+7 -3
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (sample-text formulas on Enter, #1067; previously trivial: radar, stock and other undrawn chart types show a placeholder instead of blank space, #998; previously trivial: saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -43,7 +43,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) |
| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) |
| 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) |
| 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) |
| 29 | **Equations**: ink equations; copied equations as OMML for Word (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816; Word/LibreOffice paste not yet verified); only copies containing an equation get HTML, text-only copies stay plain | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) |
**Beta needs #1–#7** (~150–230 h with the stability and layout work they imply): they are the
blocking gaps, and closing them lifts file formats, layout, stability and UI enough to take the
@@ -63,7 +63,10 @@ The same gaps grouped the way the parity documents are, for agents working in on
styles, and Draw Table and Eraser (#303) landed on 2026-10-10. Paste from other programs keeps
their formatting (system clipboard HTML/RTF), Merge Formatting keeps emphasis, Paste Special lists
what the system clipboard holds, and Linux/Wayland clipboard pictures paste (#687, #833);
still missing: the Paste Options button after a paste, and RTF on macOS and X11.
still missing: the Paste Options button after a paste, and RTF on macOS and X11. Copying an
equation puts MathML in HTML on the clipboard (#816; pasting into Word/LibreOffice not yet
verified; OMML on the clipboard is #1297); only copies containing an equation get HTML,
text-only copies stay plain text.
- **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); context menus for objects (the table's landed; it still lacks Border Styles); Linux title bar theming (#78); File ›
Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD
desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real
@@ -141,6 +144,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-10 | trivial | #10: Tight and Through wrapping follow the object's outline (#354) |
| 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright |
| 2026-10-10 | trivial | #10: Drawing Canvas landed (#344); in-canvas dragging of members remains |
| 2026-10-13 | trivial | #29: copied equations reach the clipboard as MathML in HTML (#816); OMML for Word stays open (#1297) |
| 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) |
| 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) |
| 2026-10-10 | minor | #8: Polish proofing (spelling, grammar, hyphenation) and a proofing language per run landed; the gap is now the other languages (20–40 h) |
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: a chosen interface size is absolute and Auto keeps the display scale, #822; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: copied equations reach other programs as MathML, #816; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
@@ -49,6 +49,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) |
| 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) |
| 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) |
| 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only |
| 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% |
| 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) |
| 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them |