#1273: equations from Word files keep all their content (#2613)

- Math straight in the body or a cell (m:oMathPara/m:oMath without w:p,
  allowed by EG_BlockLevelElts) gets its own paragraph instead of vanishing.
- mc:AlternateContent Requires="m" picks the equation, not its fallback.
- Inside an equation, runs in w:hyperlink, w:fldSimple, w:dir, w:bdo and
  mc:AlternateContent are kept (EG_PContentMath); depth stays bounded.
- w:sym, w:noBreakHyphen and Symbol-font text in math runs become Unicode.
- Layout: n-ary operators, delimiters, accents and group characters the math
  font lacks use a fallback font instead of a missing-glyph box.
- Coverage test reads every ECMA-376 §22.1 element and checks no argument's
  text is dropped; tests for body-level math, Requires="m", symbols, hostile
  nesting and the font fallback. Parity docs and progress log updated.


Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 11:45:01 -04:00
1 parent 473df388f3
commit dec24458cf
7 files changed
+238 -17

No files matched your search

+2
View File
@@ -180,6 +180,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). |
| 2026-10-17 | Insert › Drop Cap is a menu (None, Dropped, In margin, Drop Cap Options…); the dialog sets position, font, lines to drop and distance; letters in the margin; DOCX keeps them (#2601). |
| 2026-10-16 | An otherwise empty section starting a new page retains its paragraph line before a continuous section, matching an original native Word control. |
| 2026-10-16 | Equations from Word files keep all their content (#1273): math straight in the body or a table cell, math behind `mc:AlternateContent Requires="m"`, runs inside hyperlinks, simple fields and markup-compatibility blocks within an equation, and `w:sym`/Symbol-font characters in math; operators, delimiters and accents the math font lacks come from a fallback font. Every ECMA-376 §22.1 element is pinned by a coverage test. |
|---|---|
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
@@ -545,6 +546,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-16 | trivial | Body and active note text keep normal opacity while editing footnotes or endnotes (#2545); header/footer dimming is unchanged |
| 2026-10-17 | minor | Progress log: Drop Cap Options (#2601) |
| 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet |
| 2026-10-16 | trivial | Progress log: equations from Word files keep their content (#1273) |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+134 -5
View File
@@ -4,6 +4,7 @@ use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
};
use wordcraft_doc::props::Rgb;
use wordcraft_doc::symbol_fonts;
use crate::xml::El;
@@ -232,12 +233,21 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
e: sub_arg(k, "m:e", d),
});
}
// Tracked insertions show; deletions don't.
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => {
// Tracked insertions show; deletions don't. Hyperlinks, simple fields and direction
// wrappers (EG_PContentMath, §22.1.2.77) keep their runs (#1273).
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "w:hyperlink" | "w:fldSimple" | "w:dir" | "w:bdo" | "m:oMath" if depth <= MAX_DEPTH => {
for c in k.els() {
node(c, d, out);
}
}
// Markup compatibility: the math choice (we read math), else the fallback.
"mc:AlternateContent" if depth <= MAX_DEPTH => {
if let Some(c) = k.child("mc:Choice").or_else(|| k.child("mc:Fallback")) {
for c in c.els() {
node(c, d, out);
}
}
}
"w:sdt" => {
if let Some(c) = k.child("w:sdtContent") {
for c in c.els() {
@@ -249,13 +259,24 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
}
}
/// `m:t` (and `w:t`) text of a run.
/// `m:t` (and `w:t`) text of a run, with symbol characters (`w:sym`) and special hyphens.
/// Text in a symbol font (Symbol, Wingdings…) becomes its Unicode equivalent: the math font has
/// no glyphs at those private-use codes (#1273).
fn run_text(r: &El) -> String {
let font = r.child("w:rPr").and_then(|p| p.child("w:rFonts")).and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).unwrap_or("");
let mut s = String::new();
for c in r.els() {
match c.name.as_str() {
"m:t" | "w:t" => s.push_str(&c.text()),
"m:t" | "w:t" => s.push_str(&symbol_fonts::map_text(font, &c.text())),
"w:tab" => s.push('\t'),
"w:noBreakHyphen" => s.push('\u{2011}'),
"w:sym" => {
let ch = c.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let f = c.attr("w:font").unwrap_or(font);
s.push(symbol_fonts::symbol_char(f, ch).unwrap_or(ch));
}
}
_ => {}
}
}
@@ -308,7 +329,10 @@ fn wrpr(pr: Option<&El>, out: &mut MRun) {
out.color = Some(c);
}
if out.nor {
if let Some(f) = pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))) {
// Symbol-font text was mapped to Unicode: it takes the text font.
if let Some(f) =
pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).filter(|f| !symbol_fonts::is_symbol_font(f))
{
out.font = Some(f.to_string());
}
let bold = pr.child("w:b").is_some_and(crate::units::on_off);
@@ -364,4 +388,109 @@ mod tests {
assert!(matches!(m.nodes[2], MNode::Nary { chr: '∫', .. }));
assert!(matches!(&m.nodes[3], MNode::Matrix { rows, .. } if rows.len() == 1 && rows[0].len() == 2));
}
/// Every run's text in the tree, in reading order.
fn texts(m: &mut Math) -> Vec<String> {
let mut out = Vec::new();
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| out.push(r.text.clone()));
out
}
fn t(s: &str) -> String {
format!("<m:r><m:t>{s}</m:t></m:r>")
}
/// #1273: one equation using every Office Math element of ECMA-376 §22.1 (each argument
/// holding a distinct marker) keeps every argument's text: nothing is silently dropped.
#[test]
fn every_omml_element_keeps_its_content() {
let parts = [
format!("<m:acc><m:accPr><m:chr m:val=\"̃\"/></m:accPr><m:e>{}</m:e></m:acc>", t("A1")),
format!("<m:bar><m:barPr><m:pos m:val=\"top\"/></m:barPr><m:e>{}</m:e></m:bar>", t("B1")),
format!("<m:box><m:boxPr><m:opEmu m:val=\"1\"/></m:boxPr><m:e>{}</m:e></m:box>", t("C1")),
format!("<m:borderBox><m:borderBoxPr><m:strikeH/></m:borderBoxPr><m:e>{}</m:e></m:borderBox>", t("D1")),
format!("<m:d><m:dPr><m:begChr m:val=\"[\"/></m:dPr><m:e>{}</m:e><m:e>{}</m:e></m:d>", t("E1"), t("E2")),
format!("<m:eqArr><m:e>{}</m:e><m:e>{}</m:e></m:eqArr>", t("F1"), t("F2")),
format!("<m:f><m:fPr><m:type m:val=\"skw\"/></m:fPr><m:num>{}</m:num><m:den>{}</m:den></m:f>", t("G1"), t("G2")),
format!("<m:func><m:fName>{}</m:fName><m:e>{}</m:e></m:func>", t("H1"), t("H2")),
format!("<m:groupChr><m:groupChrPr><m:chr m:val=\"⏞\"/><m:pos m:val=\"top\"/></m:groupChrPr><m:e>{}</m:e></m:groupChr>", t("I1")),
format!("<m:limLow><m:e>{}</m:e><m:lim>{}</m:lim></m:limLow>", t("J1"), t("J2")),
format!("<m:limUpp><m:e>{}</m:e><m:lim>{}</m:lim></m:limUpp>", t("K1"), t("K2")),
format!("<m:m><m:mr><m:e>{}</m:e><m:e>{}</m:e></m:mr><m:mr><m:e>{}</m:e><m:e/></m:mr></m:m>", t("L1"), t("L2"), t("L3")),
format!(
"<m:nary><m:naryPr><m:chr m:val=\"∑\"/></m:naryPr><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:nary>",
t("M1"),
t("M2"),
t("M3")
),
format!("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr><m:e>{}</m:e></m:phant>", t("N1")),
format!("<m:rad><m:deg>{}</m:deg><m:e>{}</m:e></m:rad>", t("O1"), t("O2")),
format!("<m:sPre><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:sPre>", t("P1"), t("P2"), t("P3")),
format!("<m:sSub><m:e>{}</m:e><m:sub>{}</m:sub></m:sSub>", t("Q1"), t("Q2")),
format!("<m:sSubSup><m:e>{}</m:e><m:sub>{}</m:sub><m:sup>{}</m:sup></m:sSubSup>", t("R1"), t("R2"), t("R3")),
format!("<m:sSup><m:e>{}</m:e><m:sup>{}</m:sup></m:sSup>", t("S1"), t("S2")),
// Run-level wrappers allowed in math (EG_PContentMath): hyperlinks, simple fields,
// tracked insertions, content controls, markup compatibility.
format!("<w:hyperlink w:anchor=\"x\">{}</w:hyperlink>", t("T1")),
format!("<w:fldSimple w:instr=\"QUOTE\">{}</w:fldSimple>", t("T2")),
format!("<w:ins w:id=\"1\" w:author=\"a\">{}</w:ins>", t("T3")),
format!("<w:sdt><w:sdtContent>{}</w:sdtContent></w:sdt>", t("T4")),
format!(
"<mc:AlternateContent><mc:Choice Requires=\"m\">{}</mc:Choice><mc:Fallback><w:r><w:t>picture</w:t></w:r></mc:Fallback></mc:AlternateContent>",
t("T5")
),
"<w:r><w:t>T6</w:t></w:r>".to_string(),
];
let x = format!(
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">{}</m:oMath>"#,
parts.concat()
);
let e = crate::xml::parse(x.as_bytes()).unwrap();
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
let markers = [
"A1", "B1", "C1", "D1", "E1", "E2", "F1", "F2", "G1", "G2", "H1", "H2", "I1", "J1", "J2", "K1", "K2", "L1", "L2", "L3", "M1", "M2", "M3",
"N1", "O1", "O2", "P1", "P2", "P3", "Q1", "Q2", "R1", "R2", "R3", "S1", "S2", "T1", "T2", "T3", "T4", "T5", "T6",
];
for k in markers {
assert!(got.contains(k), "{k} dropped from {got}");
}
assert!(!got.contains("picture"), "the math choice wins over the fallback: {got}");
}
/// #1273: deleted text stays out; symbol characters and symbol-font text become Unicode.
#[test]
fn symbols_and_deletions_in_math() {
let e = omath(
r#"<m:r><m:t>x</m:t></m:r><w:del w:id="1" w:author="a"><m:r><m:t>gone</m:t></m:r></w:del><m:r><w:sym w:font="Symbol" w:char="F061"/></m:r><m:r><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr><m:t>&#xF062;</m:t><w:noBreakHyphen/></m:r><w:r><w:rPr><w:rFonts w:ascii="Symbol"/></w:rPr><w:t>&#xF067;</w:t></w:r>"#,
);
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
assert_eq!(got, "xαβ\u{2011}γ");
let mut font = None;
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| {
if r.nor {
font = Some(r.font.clone());
}
});
assert_eq!(font, Some(None), "mapped symbol text takes the text font");
}
/// Hostile nesting through the wrappers is bounded (no stack overflow, no panic).
#[test]
fn deeply_nested_wrappers_are_bounded() {
// Deeper than the math limit, within the XML parser's own depth limit.
let n = 120;
let inner = format!("{}{}{}", "<w:hyperlink>".repeat(n), t("deep"), "</w:hyperlink>".repeat(n));
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
let inner = format!(
"{}{}{}",
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:Choice Requires=\"m\">".repeat(n),
t("deep"),
"</mc:Choice></mc:AlternateContent>".repeat(n)
);
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
}
}
+9 -1
View File
@@ -178,7 +178,8 @@ fn clean_text(s: &str) -> String {
fn children_of_choice(ac: &El) -> Option<&El> {
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
// `m`: Office Math (a producer may guard an equation with a picture fallback, #1273).
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v", "m"];
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
}
@@ -231,6 +232,13 @@ impl Reader<'_> {
}
"w:sdt" => self.read_block_sdt(sc, e, rels, out, depth),
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => self.read_blocks(sc, e, rels, out, depth + 1),
// Math is allowed straight in a story (EG_BlockLevelElts takes EG_RunLevelElements,
// which takes EG_MathContent): it gets a paragraph of its own (#1273).
"m:oMathPara" | "m:oMath" => {
let wrapped = El { name: "w:p".into(), attrs: Vec::new(), kids: vec![Node::El(e.clone())] };
let p = self.read_para(sc, &wrapped, rels, depth);
out.push(para_block(p));
}
"mc:AlternateContent" => {
if let Some(c) = children_of_choice(e) {
self.read_blocks(sc, c, rels, out, depth + 1);
+38
View File
@@ -3469,3 +3469,41 @@ fn zero_effects_stay_zero_rotated() {
}
}
}
/// The math namespace, declared on the math element itself (the fixture root doesn't).
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
fn equations(p: &Paragraph) -> Vec<(String, bool)> {
p.objects
.iter()
.filter_map(|o| match o {
InlineObject::Equation { linear, display, .. } => Some((linear.clone(), *display)),
_ => None,
})
.collect()
}
/// #1273: an equation straight in the body (EG_BlockLevelElts allows math content) gets a
/// paragraph of its own instead of vanishing.
#[test]
fn body_level_equation_is_kept() {
let d = read_body(&format!(
r#"<w:p><w:r><w:t>before</w:t></w:r></w:p><m:oMathPara {M_NS}><m:oMath><m:r><m:t>a=b</m:t></m:r></m:oMath></m:oMathPara><m:oMath {M_NS}><m:r><m:t>c</m:t></m:r></m:oMath><w:p><w:r><w:t>after</w:t></w:r></w:p>"#
));
let ps = paras(&d);
assert_eq!(ps.len(), 4, "{:?}", ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>());
assert_eq!(equations(ps[1]), vec![("a=b".to_string(), true)]);
assert_eq!(equations(ps[2]), vec![("c".to_string(), false)]);
}
/// #1273: an equation guarded by markup compatibility (`Requires="m"`) is read as an equation,
/// not replaced by its fallback.
#[test]
fn equation_in_alternate_content_is_read() {
let d = read_body(&format!(
r#"<w:p><mc:AlternateContent {M_NS}><mc:Choice Requires="m"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></mc:Choice><mc:Fallback><w:r><w:t>[picture]</w:t></w:r></mc:Fallback></mc:AlternateContent></w:p>"#
));
let p = paras(&d)[0];
assert_eq!(equations(p), vec![("x+1".to_string(), false)]);
assert!(!p.plain_text().contains("[picture]"));
}
+51 -9
View File
@@ -658,6 +658,18 @@ impl Ctx {
b
}
/// `c` in the math font, or in a fallback font when the math font lacks it (an n-ary
/// operator, delimiter or accent the font doesn't cover would otherwise draw as a missing
/// glyph box, #1273).
fn char_glyphs(&self, c: char, size: f32, level: u8) -> Bx {
let face = if self.face.covers(c) {
self.face
} else {
FontDb::global().fallback_for(c, self.face.id()).map(|f| FaceRef::of(&f)).unwrap_or(self.face)
};
self.glyphs(face, &c.to_string(), size, level, self.color, (false, false))
}
/// A single glyph by id (size variants, assembly parts).
fn glyph_id(&self, gid: u32, size: f32, color: Rgb) -> Bx {
let face = self.face;
@@ -804,7 +816,11 @@ impl Ctx {
if *grow {
target = target.max(e.a + e.d);
}
let op = if *grow && target > 0.0 && self.t.is_some() {
let op = if base_gid == 0 {
// The math font lacks the operator: a fallback font's glyph, display-sized.
let s = if st.display { size * 1.4 } else { size };
self.char_glyphs(*chr, s, 0)
} else if *grow && target > 0.0 && self.t.is_some() {
self.stretch_v(base_gid, target, size).unwrap_or_else(|| self.glyph_id(base_gid, size, self.color))
} else if target > 0.0 && self.t.is_some() {
// Word sets display operators about 1.2 em tall: the size variant nearest that.
@@ -820,11 +836,8 @@ impl Ctx {
let f = if adv > 0.0 { (want / adv).clamp(0.6, 1.5) } else { 1.0 };
let f = if (0.9..=1.1).contains(&f) { 1.0 } else { f };
self.glyph_id(g, size * f, self.color)
} else if base_gid != 0 {
self.glyph_id(base_gid, size, self.color)
} else {
let s = if st.display { size * 1.4 } else { size };
self.glyphs(self.face, &chr.to_string(), s, 0, self.color, (false, false))
self.glyph_id(base_gid, size, self.color)
};
// Centre the operator on the math axis.
let dy = self.axis(st) - (op.a - op.d) / 2.0;
@@ -1037,7 +1050,7 @@ impl Ctx {
fn delimiter(&self, c: char, a: f32, d: f32, st: St, grow: bool, exact: bool) -> Bx {
let size = self.size(st);
let axis = self.axis(st);
let mut glyph = self.glyphs(self.face, &c.to_string(), size, 0, self.color, (false, false));
let mut glyph = self.char_glyphs(c, size, 0);
glyph.glyph = None;
if !grow {
return glyph;
@@ -1210,8 +1223,7 @@ impl Ctx {
let b = self.sub(0, e, st, d);
let size = self.size(st);
let gid = self.face.glyph_for(*chr);
let g =
self.stretch_h(gid, b.w, size).unwrap_or_else(|| self.glyphs(self.face, &chr.to_string(), size, 0, self.color, (false, false)));
let g = self.stretch_h(gid, b.w, size).unwrap_or_else(|| self.char_glyphs(*chr, size, 0));
let w = b.w.max(g.w);
let (ba, bd, bw) = (b.a, b.d, b.w);
let (ga, gd, gw) = (g.a, g.d, g.w);
@@ -1396,7 +1408,7 @@ impl Ctx {
} else if gid != 0 {
self.glyph_id(gid, size, color)
} else {
self.glyphs(self.face, &chr.to_string(), size, 0, color, (false, false))
self.char_glyphs(chr, size, 0)
};
acc.glyph = None;
// Horizontal: the base's attachment point under the accent's.
@@ -2144,6 +2156,36 @@ mod tests {
}
}
/// #1273: an n-ary operator, delimiter, accent or group character the math font lacks is
/// drawn from a fallback font, not as a missing-glyph box.
#[test]
fn characters_the_math_font_lacks_fall_back() {
let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math");
let Some(c) =
['⨌', '⫿', '⟅', 'ア', '中', '가'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some())
else {
return; // Every candidate is in the math font, or no fallback font is installed.
};
let x = || vec![MNode::Run(MRun::new("x"))];
let nodes = [
MNode::Nary { chr: c, lim_loc: None, grow: false, sub_hide: true, sup_hide: true, sub: vec![], sup: vec![], e: x() },
MNode::Delim { beg: Some(c), end: Some(c), sep: None, grow: true, shp_match: false, elems: vec![x()] },
MNode::Acc { chr: c, e: x() },
MNode::GroupChr { chr: c, top: true, e: x() },
];
for display in [false, true] {
for n in &nodes {
let m =
layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), display, 400.0, &MathProps::default(), &mut 0);
for it in &m.items {
if let MItem::Glyphs { glyphs, .. } = it {
assert!(glyphs.iter().all(|g| g.0 != 0), "{c:?} drew a missing glyph in {n:?}");
}
}
}
}
}
#[test]
fn caret_slots_cover_every_position() {
use wordcraft_doc::math_edit::{MathPos, move_right, units};
+2 -1
View File
@@ -100,7 +100,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. Ink members of groups (`wpg:wgp`, nested groups too) and drawing canvases (`wpc:wpc`), bare or in `mc:AlternateContent` with a `pic:pic` fallback, keep their markup, part, relationship and place among the members; they are written at the member's current position in the group and drawn from their fallback picture. Such a group can't be ungrouped yet (Ungroup is unavailable, so the ink is never left in a form it can't be saved in). Their part is dropped with the group (no orphan part); ink whose part is missing or isn't InkML gives way to its fallback, else is dropped. No native stroke rendering, eraser, conversion or replay |
| 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326). Every ECMA-376 §22.1 element is read and laid out (pinned by `read::math::tests::every_omml_element_keeps_its_content`); equations straight in the body or a cell (no `w:p`), behind `mc:AlternateContent Requires="m"`, or with runs inside hyperlinks, simple fields, content controls and tracked insertions inside the equation keep their content; `w:sym` and Symbol-font text in math become Unicode (#1273). Not yet: Equation Editor 3.0 / MathType OLE objects show their WMF preview only (text in #1994), `m:mathFont` (Cambria Math is always asked for), `m:dispDef`/`m:defJc`/`m:lMargin`, tracked deletions shown struck through inside equations |
| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`, `w:embedBold`…) lay out and export the text set in them for the session (#1176), subsets Word embeds ("embed only the characters used", a made-up family such as `___WRD_EMBED_SUB_46` inside) found by the font table's name (#2240); the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip |
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) |
| Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) |
@@ -259,6 +259,7 @@ Imported text fields use the first nonempty cached result run's formatting on th
| 2026-10-15 | trivial | Native ink nested in groups and drawing canvases kept on save, in its place, drawn from its fallback picture |
| 2026-10-18 | trivial | DOCX: every VML shape anchored in a header table cell is placed on the page whatever `o:allowincell` says (user-checked in Word for Mac, #1775) |
| 2026-10-16 | trivial | DOCX: `wp:anchor/@layoutInCell="0"` and VML `o:allowincell="f"` are read and written back (`layoutInCell="0"`), so a header-cell picture watermark is placed on the page (#1775) |
| 2026-10-16 | trivial | Equations from Word files no longer lose content: body-level `m:oMathPara`, `mc:AlternateContent Requires="m"`, hyperlinks/simple fields/`mc:AlternateContent` inside an equation, `w:sym` and Symbol-font text in math (#1273) |
| 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) |
| 2026-10-14 | trivial | Row table border exceptions survive DOCX saves |
| 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) |
+2 -1
View File
@@ -74,7 +74,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
| Ruby / phonetic guide, enclose characters, combined characters | 🟡 | ruby (Phonetic Guide) landed (#288): ruby text over its base by alignment, line grows to fit, in PDF; enclose and combined characters missing; ruby not yet compared with Word page by page | 2–4 |
| Automatic space between Asian text and Latin text or digits (`w:autoSpaceDE`, `w:autoSpaceDN`) | 🟡 | #2358: a quarter of the Asian character's font size after the first of two touching characters, across runs (measured on Word's PDF of a Chinese document at 16 pt, as JIS X 4051 asks); not beside East Asian punctuation, Hangul, Latin punctuation or an existing space; a field is classed by the text it shows; none at a line's end, none on a character grid the paragraph snaps to (`layout::tests_autospace`). Not yet: justification widening the space as Word does, `autoSpaceLikeWord95` | 1–2 |
| Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 |
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — |
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326); an n-ary operator, delimiter, accent or group character the math font lacks is drawn from a fallback font instead of a missing-glyph box (#1273) | — |
| Hidden text excluded from breaking and hyphenation | ✅ | #173 | — |
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451) | — |
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. Most other legacy compat options remain ignored | 10–20 |
@@ -233,6 +233,7 @@ Footer flow-table strokes reserve their drawn top edge, including the half-width
| 2026-10-16 | trivial | A header or footer cell's float placed relative to the page and not laid out in its cell (a picture watermark in a header that starts with a table) is placed on the page, behind the text (#1775) |
| 2026-10-17 | minor | Drop caps: In margin (letter in the left margin, text not indented), distance from text and the letter's font, round-tripped as `w:framePr` (#2601) |
| 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet |
| 2026-10-16 | trivial | Math operators, delimiters and accents missing from the math font use a fallback font (#1273) |
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |