diff --git a/ROADMAP.md b/ROADMAP.md index 6744579..bef690d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources | | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | | 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources | +| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing | | 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | | 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | | 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions | @@ -262,6 +263,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages | | 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages | +| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing | | 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) | | 2026-10-10 | trivial | Progress log: Draw tab ink (#307) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) | diff --git a/crates/doc/src/graphic.rs b/crates/doc/src/graphic.rs index 091a0b4..c6e448f 100644 --- a/crates/doc/src/graphic.rs +++ b/crates/doc/src/graphic.rs @@ -1,4 +1,4 @@ -//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts and SmartArt +//! Charts, SmartArt diagrams and native ink: drawings kept as neutral items ready to draw. Charts and SmartArt //! WordCraft can edit also keep their model ([`crate::chart::ChartSpec`], //! [`crate::smart_art::SmartArtSpec`]); the rest are shown, not edited. @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::para::ShapeKind; use crate::props::Rgb; -/// A chart or a SmartArt diagram, as items ready to draw (and, for an editable chart or SmartArt +/// A chart, SmartArt diagram or native ink preview, as items ready to draw (and, for an editable chart or SmartArt /// graphic, its model). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -22,7 +22,7 @@ pub struct Graphic { pub w: f32, #[serde(default)] pub h: f32, - /// Where the graphic was read from, so saving writes the chart or diagram itself back (not + /// Where the graphic was read from, so saving writes the original drawing itself back (not /// only these items). `None` for graphics made some other way. #[serde(skip)] pub source: Option>, @@ -44,7 +44,8 @@ pub struct Graphic { #[derive(Clone, Debug, Default, PartialEq)] pub struct Embedded { /// The object's markup as a standalone XML element with its namespace declarations (DOCX: - /// the `a:graphic` of a chart or SmartArt diagram, the `w:object` of an OLE object). + /// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`, + /// or the `w:object` of an OLE object). pub xml: String, /// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing). pub refs: Vec, @@ -68,6 +69,8 @@ pub enum GraphicKind { #[default] Chart, Diagram, + /// Word's native InkML, kept opaque; displayed as its fallback picture when available. + Ink, } impl GraphicKind { @@ -76,6 +79,7 @@ impl GraphicKind { match self { GraphicKind::Chart => "chart", GraphicKind::Diagram => "diagram", + GraphicKind::Ink => "ink", } } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 617708e..05ca4ea 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -14,11 +14,14 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi use super::Reader; use super::props::{sectpr, tcpr, trpr}; use super::shape_style::{preset_kind, shape_paint, vml_color}; -use crate::package::{Rels, rt}; +use crate::package::{ContentTypes, Rels, rt}; use crate::units::{int, measure, u32_of}; -use crate::xml::El; +use crate::xml::{El, Node}; use wordcraft_doc::wordart::TextWarp; +const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"; +const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml"; + /// Deepest table nesting we build (deeper tables become plain paragraphs). const MAX_TABLE_DEPTH: usize = 24; /// Deepest field nesting we track. @@ -163,7 +166,7 @@ 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", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"]; + const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"]; 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")) } @@ -500,7 +503,10 @@ impl Reader<'_> { } } "mc:AlternateContent" => { - if let Some(c) = children_of_choice(k) { + if let Some(o) = self.read_ink_alternate(sc, k, rels) { + let props = self.run_props_none(ctx); + self.emit_obj(sc, pb, o, &props); + } else if let Some(c) = children_of_choice(k) { self.read_inline_children(sc, pb, c, rels, ctx, depth + 1); } } @@ -661,7 +667,9 @@ impl Reader<'_> { } "w:ruby" => self.read_ruby(sc, pb, k, props), "mc:AlternateContent" => { - if let Some(c) = children_of_choice(k) { + if let Some(o) = self.read_ink_alternate(sc, k, rels) { + self.emit_obj(sc, pb, o, props); + } else if let Some(c) = children_of_choice(k) { for x in c.els() { self.read_run_child(sc, pb, x, props, rels, note, depth + 1); } @@ -881,6 +889,25 @@ impl Reader<'_> { let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?; // Checked by URI before the blip search: a diagram's data can hold a blip further down. let uri = gd.attr("uri").unwrap_or(""); + if uri == INK_URI { + let content = gd.child("w14:contentPart")?; + let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?; + let pkg = self.pkg; + if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") { + return None; + } + let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?; + float.set_spin(xfrm_spin(content.child("w14:xfrm"))); + let (px, py) = float.spin_pad(w, h); + for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) { + *e = (*e - p).clamp(0.0, 1584.0); + } + // The renderer's existing missing-picture box marks ink without a usable preview. + // InkML stays opaque: no approximation of its strokes or conversion to freeforms. + let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media: String::new() }]; + let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, items, w, h, source: Some(source), ..Default::default() }); + return Some(InlineObject::Graphic { w, h, alt, float, graphic }); + } let graphic_kind = if uri.ends_with("/chart") { Some(GraphicKind::Chart) } else if uri.ends_with("/diagram") { @@ -892,7 +919,7 @@ impl Reader<'_> { // Kept so saving writes the chart or diagram back: the frame around it is written // from the object's (possibly moved or resized) size and position. let extra: Vec = match kind { - GraphicKind::Chart => Vec::new(), + GraphicKind::Chart | GraphicKind::Ink => Vec::new(), GraphicKind::Diagram => self.diagram_drawing_rel(gd, rels).into_iter().collect(), }; let source = c.child("a:graphic").and_then(|g| self.keep_embedded(g, rels, &extra)); @@ -938,6 +965,34 @@ impl Reader<'_> { Some(obj) } + /// Keep the selected native-ink branch and its picture fallback as one object. Reading only + /// the fallback flattens the ink; reading only the choice loses its preview on the next save. + fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option { + let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?; + let drawing = choice.child("w:drawing")?; + if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) { + return None; + } + let fallback = ac.child("mc:Fallback"); + let preview = fallback.and_then(|f| f.child("w:pict")).and_then(|p| self.read_vml(sc, p, rels)); + let Some(mut object) = self.read_drawing(sc, drawing, rels) else { return preview }; + if let InlineObject::Graphic { w, h, graphic, .. } = &mut object { + let graphic = Arc::make_mut(graphic); + if let Some(InlineObject::Image { media, .. }) = preview { + graphic.items = vec![GraphicItem::Image { rect: [0.0, 0.0, *w, *h], media }]; + } + if let Some(fallback) = fallback { + let kept = + El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] }; + if let Some(source) = self.keep_embedded(&kept, rels, &[]) { + graphic.source = Some(source); + } + // A malformed or over-budget fallback must not discard a valid native part. + } + } + Some(object) + } + /// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left /// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word /// stores beside them. Built once per part and size; once the file's graphic budget is spent, @@ -946,6 +1001,7 @@ impl Reader<'_> { let part = match kind { GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)), GraphicKind::Diagram => self.diagram_part(gd, rels), + GraphicKind::Ink => None, }; let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None, smart_art: None }) }; let key = (kind, path, w.to_bits(), h.to_bits()); @@ -978,6 +1034,7 @@ impl Reader<'_> { None => Vec::new(), }, GraphicKind::Diagram => self.diagram_drawing(path, w, h), + GraphicKind::Ink => Vec::new(), } } @@ -1649,7 +1706,9 @@ struct Xfrm { /// The rotation and flips of the `a:xfrm` in shape properties `sppr` (ECMA-376 §20.1.7.6: /// `rot` in 60000ths of a degree, clockwise; `flipH`, `flipV`). None when missing or junk. fn xfrm_spin(sppr: Option<&El>) -> wordcraft_geom::Spin { - let Some(x) = sppr.and_then(|s| s.child("a:xfrm")) else { return wordcraft_geom::Spin::default() }; + let Some(x) = sppr.and_then(|s| if matches!(s.name.as_str(), "a:xfrm" | "w14:xfrm") { Some(s) } else { s.child("a:xfrm") }) else { + return wordcraft_geom::Spin::default(); + }; let rot = x.attr("rot").and_then(int).map(|v| v.rem_euclid(21_600_000) as f32 / 60_000.0).unwrap_or(0.0); wordcraft_geom::Spin::new(rot, on_off_attr(x, "flipH"), on_off_attr(x, "flipV")) } diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 8d43ec6..c76012a 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -1,4 +1,4 @@ -//! Writing back charts, SmartArt diagrams and OLE objects read from a file (see `read::embed`). +//! Writing back charts, SmartArt diagrams, native ink and OLE objects read from a file (see `read::embed`). //! //! Each written object gets its kept markup with fresh relationship ids, pointing at copies of the //! parts it needs. The first object to use a part writes it under its original name (unless that @@ -16,7 +16,7 @@ use super::PartRels; use crate::package::{ CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt, }; -use crate::units::emu; +use crate::units::{MAX_LEN_PT, emu, int}; use crate::xml::{self, El, MAX_DEPTH}; /// Most bytes written for copies of parts (objects pasted more than once). @@ -251,6 +251,12 @@ impl super::Writer<'_> { /// the object's current size and position into its own markup (a chart or diagram's frame is /// written by the caller). `None` when it can't be written back (its parts are gone). pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option { + self.embedded_tree(src, rels, resize).map(|root| root.to_xml()) + } + + /// The same bounded preservation operation, retaining the parsed tree for a caller that + /// must rebuild an ink AlternateContent drawing frame without reparsing unbound prefixes. + pub(super) fn embedded_tree(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option { let Some(left) = self.embeds.markup_bytes.checked_sub(src.xml.len() as u64) else { log::warn!("docx: too much embedded-object markup; the rest is left out"); return None; @@ -305,7 +311,7 @@ impl super::Writer<'_> { if let Some((w, h, float, alt)) = resize { self.resize_object(&mut root, w, h, float, alt, 0); } - Some(root.to_xml()) + Some(root) } /// The `a:graphic` of a SmartArt graphic written from its model, laid out `w` × `h` points: @@ -437,6 +443,51 @@ impl super::Writer<'_> { } } } + + /// Ink's own transform need not fill its outer drawing frame. Scale its original offsets + /// and extents proportionally; an unrelated edit must not stretch the preserved strokes. + pub(super) fn ink_transform(e: &mut El, from: (f32, f32), to: (f32, f32), float: &Float, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if e.name == "w14:xfrm" { + e.attrs.retain(|(k, _)| !matches!(k.as_str(), "rot" | "flipH" | "flipV")); + for (k, v) in super::story::spin_attrs(float.spin()) { + e.attrs.push((k.to_string(), v)); + } + let scale = |old: f32, new: f32| if old.is_finite() && old > 0.0 && new.is_finite() { (new.max(0.0) / old) as f64 } else { 1.0 }; + let (sx, sy) = (scale(from.0, to.0), scale(from.1, to.1)); + let max = MAX_LEN_PT as f64 * 12_700.0; + for c in e.kids.iter_mut().filter_map(|k| match k { + xml::Node::El(c) => Some(c), + _ => None, + }) { + if !matches!(c.name.as_str(), "a:off" | "a:ext") { + continue; + } + for (key, value) in &mut c.attrs { + let factor = match key.as_str() { + "x" | "cx" => sx, + "y" | "cy" => sy, + _ => continue, + }; + if factor != 1.0 + && let Some(n) = int(value) + { + let min = if c.name == "a:ext" { 0.0 } else { -max }; + *value = ((n as f64 * factor).clamp(min, max).round() as i64).to_string(); + } + } + } + return; + } + for c in e.kids.iter_mut().filter_map(|k| match k { + xml::Node::El(c) => Some(c), + _ => None, + }) { + Self::ink_transform(c, from, to, float, depth + 1); + } + } } /// Set an extent's `cx` and `cy` (those it has) to `w` × `h` points. diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index a7f0997..830184a 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -524,38 +524,50 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } - // A chart or diagram read from a file: its frame from the object's size and position, - // the graphic inside as read. Ones made some other way have nothing to write, except - // charts and SmartArt with a model (made or edited in WordCraft): their parts are - // written from it. + // Editable charts and SmartArt keep main's model writer; opaque graphics and native + // ink keep their original source and related parts. InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => { - let inner = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { - // A chart WordCraft can edit: a chart part written from its model. - (Some(spec), _, _) => self.chart_graphic(spec, rels), - (None, Some(spec), _) => self.smart_art_graphic(spec, *gw, *gh, rels), - (None, None, Some(src)) => { - let Some(inner) = self.embedded_xml(src, rels, None) else { return }; - inner - } - (None, None, None) => return, + let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; + let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { + (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), + (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), + (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), + (None, None, None) => None, }; - if inner.is_empty() { + let Some(mut root) = root else { return }; + if ink { + Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); + } + let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root); + let fallback = alternate.and_then(|e| e.child("mc:Fallback")); + let native = alternate.and_then(|e| e.child("mc:Choice")).and_then(|e| e.find("a:graphic")); + if alternate.is_some() && native.is_none() { return; } self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props); + if fallback.is_some() { + w.open("mc:AlternateContent", &[]); + w.open("mc:Choice", &[("Requires", "wpi")]); + } w.open("w:drawing", &[]); let docpr = self.next_docpr(); let name = match graphic.kind { wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), + wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), }; self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt); w.empty("wp:cNvGraphicFramePr", &[]); - w.raw(&inner); + w.raw(&native.unwrap_or(&root).to_xml()); w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); w.close("w:drawing"); + if let Some(fallback) = fallback { + w.close("mc:Choice"); + w.raw(&fallback.to_xml()); + w.close("mc:AlternateContent"); + } w.close("w:r"); self.rev_close(w, props); } diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 925122c..2d47478 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -6,7 +6,7 @@ use quick_xml::XmlVersion; use quick_xml::events::Event; -use quick_xml::name::ResolveResult; +use quick_xml::name::{QName, ResolveResult}; use quick_xml::reader::NsReader; use crate::DocxError; @@ -26,6 +26,7 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"), ("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), ("w14", "http://schemas.microsoft.com/office/word/2010/wordml"), + ("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"), ("w15", "http://schemas.microsoft.com/office/word/2012/wordml"), ("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"), ("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"), @@ -81,6 +82,24 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str { "?" } +/// Compatibility feature namespaces whose elements are not otherwise handled by this tree. +/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases. +fn requirement_prefix(uri: &[u8]) -> &'static str { + let prefix = prefix_for(uri, &[]); + if prefix != "?" { + return prefix; + } + match uri { + b"http://schemas.microsoft.com/office/drawing/2010/main" => "a14", + b"http://schemas.microsoft.com/office/word/2015/wordml/symex" => "w16se", + b"http://schemas.microsoft.com/office/word/2016/wordml/cid" => "w16cid", + b"http://schemas.microsoft.com/office/word/2018/wordml" => "w16", + b"http://schemas.microsoft.com/office/word/2018/wordml/cex" => "w16cex", + b"http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" => "w16sdtdh", + _ => "?", + } +} + #[derive(Clone, Debug, PartialEq)] pub enum Node { El(El), @@ -288,6 +307,28 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result v.into_owned(), Err(_) => String::from_utf8_lossy(&a.value).into_owned(), }; + // Requires contains namespace prefixes, not feature names. Resolve them while + // the namespace scope still exists; declarations are omitted from this tree. + let val = if el.name == "mc:Choice" && aname == "Requires" { + let requirements: Vec<_> = val.split_whitespace().take(65).collect(); + if requirements.len() > 64 || requirements.iter().any(|prefix| prefix.len() > 128) { + "?".to_string() + } else { + requirements + .into_iter() + .map(|prefix| { + let qualified = format!("{prefix}:requirement"); + match r.resolver_mut().resolve_attribute(QName(qualified.as_bytes())).0 { + ResolveResult::Bound(ns) => requirement_prefix(ns.as_ref()), + _ => "?", + } + }) + .collect::>() + .join(" ") + } + } else { + val + }; el.attrs.push((aname, val)); } if matches!(ev, Event::Start(_)) { @@ -489,6 +530,14 @@ mod tests { assert_eq!(t.attr("xml:space"), Some("preserve")); } + #[test] + fn choice_requirements_resolve_namespace_aliases_and_rebindings() { + let x = br#""#; + let e = parse(x).unwrap(); + let values: Vec<_> = e.children("mc:Choice").map(|c| c.attr("Requires")).collect(); + assert_eq!(values, [Some("wpi"), Some("?"), Some("?")]); + } + #[test] fn deep_nesting_is_error() { let mut s = String::new(); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 9742806..e3b95d1 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1513,3 +1513,208 @@ fn vml_lines_and_rectangles() { } assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. })); } + +// ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ---- + +const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"; +const INK_XML: &str = r##"0 0, 1 1, 2 0"##; +const INK_TYPES: &str = r#""#; + +fn ink_docx(fallback: bool, alias: bool) -> Vec { + let content = r#""#; + let drawing = format!( + r#"{content}"# + ); + let prefix = if alias { "pen" } else { "wpi" }; + let markup = if fallback { + format!( + r##"{drawing}"## + ) + } else { + drawing + }; + let body = format!("Original{markup}"); + let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")]; + let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)]; + if fallback { + relationships.push(("rPreview", "image", "media/preview.png")); + extra.push(("word/media/preview.png", "original preview bytes")); + } + docx(&body, &relationships, &extra) +} + +fn check_ink(files: &std::collections::BTreeMap>) -> String { + let body = text(files, "word/document.xml"); + let [id] = attr_values(&body, "w14:contentPart", "r:id").try_into().unwrap_or_else(|_| panic!("ink reference lost: {body}")); + let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), &id); + assert!(kind.ends_with("/customXml")); + let part = part_at("word", &target); + assert_eq!(text(files, &part), INK_XML); + assert!(text(files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#))); + part +} + +#[test] +fn native_ink_survives_unrelated_edit_and_two_saves() { + let mut d = wordcraft_docx::read(&ink_docx(false, false)).unwrap(); + let g = graphics(&d); + assert_eq!(g.len(), 1, "native ink must remain a model object"); + let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() }; + assert_eq!(graphic.kind.noun(), "ink"); + assert!(graphic.source.is_some()); + assert!(!graphic.items.is_empty(), "a missing preview must not silently leave a blank frame"); + let p = std::sync::Arc::make_mut(&mut d.body[0]); + let Block::Para(p) = p else { panic!() }; + p.delete(0, p.len()).unwrap(); + p.insert_text(0, "Edited", &Default::default()).unwrap(); + for _ in 0..2 { + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + check_ink(&files); + assert!(text(&files, "word/document.xml").contains("Edited")); + d = wordcraft_docx::read(&out).unwrap(); + assert_eq!(graphics(&d).len(), 1); + } +} + +#[test] +fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() { + for alias in [false, true] { + let mut d = wordcraft_docx::read(&ink_docx(true, alias)).unwrap(); + let g = graphics(&d); + assert_eq!(g.len(), 1, "fallback must not replace native ink"); + let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() }; + assert!( + graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media))) + ); + for _ in 0..2 { + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + check_ink(&files); + let body = text(&files, "word/document.xml"); + assert!(body.contains("") && body.contains("")); + let [id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap(); + let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id); + assert!(kind.ends_with("/image")); + assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes"); + d = wordcraft_docx::read(&out).unwrap(); + assert_eq!(graphics(&d).len(), 1); + } + } +} + +#[test] +fn native_ink_missing_part_uses_available_fallback() { + let bytes = ink_docx(true, true); + let mut files = unzip(&bytes); + files.remove("word/ink/ink1.xml"); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + assert!(graphics(&d).is_empty()); + assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. }))); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + assert!(!text(&files, "word/document.xml").contains("w14:contentPart")); + assert!(files.values().any(|bytes| bytes == b"original preview bytes")); +} + +#[test] +fn native_ink_missing_optional_preview_keeps_native_part() { + let mut files = unzip(&ink_docx(true, false)); + files.remove("word/media/preview.png"); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + assert_eq!(graphics(&d).len(), 1); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + check_ink(&files); + assert!(!text(&files, "word/document.xml").contains("")); +} + +#[test] +fn native_ink_requires_the_bound_namespace_and_ink_content_type() { + for wrong_namespace in [true, false] { + let mut files = unzip(&ink_docx(true, false)); + let name = if wrong_namespace { "word/document.xml" } else { "[Content_Types].xml" }; + let old = text(&files, name); + let changed = if wrong_namespace { + old.replace(&format!(r#"xmlns:wpi="{INK_NS}""#), r#"xmlns:wpi="urn:future:unsupported""#) + } else { + old.replace("application/inkml+xml", "application/xml") + }; + files.insert(name.into(), changed.into_bytes()); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + assert!(graphics(&d).is_empty()); + assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. }))); + } +} + +#[test] +fn native_ink_copies_have_independent_parts_and_current_frames() { + let mut d = wordcraft_docx::read(&ink_docx(true, false)).unwrap(); + let [mut object] = graphics(&d).try_into().unwrap(); + let InlineObject::Graphic { w, h, float, .. } = &mut object else { panic!() }; + (*w, *h) = (288.0, 144.0); + *float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 36.0, ..Default::default() }; + let mut p = Paragraph::new(); + p.insert_object(0, object.clone(), &Default::default()).unwrap(); + p.insert_object(p.len(), object, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + let body = text(&files, "word/document.xml"); + let ids = attr_values(&body, "w14:contentPart", "r:id"); + assert_eq!(ids.len(), 2); + let parts: Vec<_> = ids.iter().map(|id| part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), id).1)).collect(); + assert_ne!(parts[0], parts[1]); + for part in parts { + assert_eq!(text(&files, &part), INK_XML); + } + assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"]); + assert_eq!(attr_values(&body, "a:ext", "cx"), ["3657600", "3657600"]); + assert_eq!(attr_values(&body, "v:shape", "style"), ["width:288pt;height:144pt", "width:288pt;height:144pt"]); + assert_eq!(body.matches("914400").count(), 2); + let back = wordcraft_docx::read(&out).unwrap(); + let g = graphics(&back); + assert_eq!(g.len(), 2); + for object in g { + let InlineObject::Graphic { w, h, float, .. } = object else { panic!() }; + assert_eq!((w, h, float.x, float.y), (288.0, 144.0, 72.0, 36.0)); + } +} + +#[test] +fn native_ink_markup_limit_does_not_write_a_dangling_reference() { + let mut files = unzip(&ink_docx(false, false)); + let body = text(&files, "word/document.xml").replace(" = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + assert!(!text(&files, "word/document.xml").contains("w14:contentPart")); +} + +#[test] +fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves() { + let mut files = unzip(&ink_docx(false, false)); + let body = text(&files, "word/document.xml") + .replace("", r#""#) + .replace(r#""#, r#""#); + files.insert("word/document.xml".into(), body.into_bytes()); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let mut d = wordcraft_docx::read(&zip(&entries)).unwrap(); + for _ in 0..2 { + let [object] = graphics(&d).try_into().unwrap(); + let InlineObject::Graphic { float, .. } = object else { panic!() }; + assert_eq!(float.spin().deg, 45.0); + assert!(float.effect.iter().all(|v| v.abs() < 0.001), "rotation padding is not counted twice: {:?}", float.effect); + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + check_ink(&files); + let body = text(&files, "word/document.xml"); + assert_eq!(attr_values(&body, "a:off", "x"), ["127000"]); + assert_eq!(attr_values(&body, "a:ext", "cx"), ["914400"]); + assert_eq!(attr_values(&body, "w14:xfrm", "rot"), ["2700000"]); + d = wordcraft_docx::read(&out).unwrap(); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c5da6ca..0b38c64 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -67,7 +67,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | | 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 | -| Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save | +| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; run-level `mc:AlternateContent` keeps the native branch and VML picture fallback. Shows the preview or a missing-picture box. Repeated saves, copies, resize, rotation and namespace aliases tested with original fixtures. No native stroke rendering, eraser, conversion or replay; direct run-level content parts and ink nested in groups/canvases remain unsupported | +| 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) | | Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | | 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) | @@ -131,8 +132,8 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi 1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with Word (local only: Word output can't be committed; keep a manifest and our own renders). -2. **Preserve what we can't render**: ink and drawing canvases survive a round trip - (keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319). +2. **Preserve what we can't render**: remaining ink forms and unsupported graphics survive a round trip + (keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319); drawing-level native ink now keeps its InkML and optional picture fallback. 3. Move tracking (`w:moveFrom`/`w:moveTo`) read and written (formatting revisions: #41; content controls: #381). 4. Encrypted `.docx` opens with a password. @@ -191,6 +192,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-11 | minor | Preserve imported TOC ranges independently of paragraph styles, with explicit evaluation limits | | 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` | | 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) | +| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | | 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | | 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed | diff --git a/docs/gaps.md b/docs/gaps.md index c7bc6fd..ff66ff8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -14,7 +14,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | # | Gap | B | Evidence | Impact | Hours | Doc | |---|---|---|---|---|---|---| | 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | -| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | +| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | @@ -24,7 +24,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | -| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | +| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, rendering/editing Word's own ink (drawing-level `w14:contentPart`/InkML now preserved on DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | @@ -102,6 +102,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) | | 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | +| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | | 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth | | 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index f753b35..ef1a7a7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,8 +14,9 @@ Opus 5.5 agent wall-clock. 2. **DOCX real-world corpus** (gap #1, 30–50 h): collect openly licensed real-world .docx files, open/render/round-trip them, compare with Word locally, fix what breaks. Omitted/empty paragraph defaults now have synthetic save/reopen coverage; the corpus gate remains open. -3. **Preserve what we can't render** (gap #2, 10–15 h): charts, SmartArt, OLE, ink, group shapes - survive a round trip and show their fallback pictures. +3. **Preserve what we can't render** (gap #2, 10–15 h): remaining native ink forms and unsupported + graphics survive a round trip. Drawing-level native ink now keeps its InkML and optional picture + fallback alongside chart, SmartArt, OLE and drawing-canvas support. 4. **Stability sweep** (gap #3, 20–30 h): the open crash, hang and write-failure issues, plus an autosave/recovery soak test. 5. **Pagination fidelity** (gap #4, 30–50 h): column balancing, footnote continuation, compat @@ -75,6 +76,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | 2026-10-11 | trivial | M15: Polish interface and Polish proofing landed | | 2026-10-11 | trivial | M3: icons landed (#309) | | 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) | +| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | | 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) | | 2026-10-11 | trivial | M7: formatting revisions landed (#41) | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |