From e486f7c39d1ca4784fd901c780600f8d147f2539 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:47:59 +0200 Subject: [PATCH] DOCX: keep Office 2016 charts (cx:chart) and their data when saving (#1029) An mc:AlternateContent whose Choice holds a cx:chart is read as a read-only chart drawn from its fallback picture, keeping both branches and every part they reach (chartEx part, style, colours, workbook, picture). Saving writes the AlternateContent back with the chart's current frame on both branches, instead of only the fallback picture. Co-authored-by: Brandon Thomas --- crates/docx/src/read/story.rs | 29 +++++++++++- crates/docx/src/write/story.rs | 41 ++++++++++++----- crates/docx/src/xml.rs | 10 +++++ crates/docx/tests/fixtures.rs | 82 ++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 12 deletions(-) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index d30270b2d..1c28338a5 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -21,6 +21,8 @@ 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"; +/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`). +const CHARTEX_URI: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; /// Deepest table nesting we build (deeper tables become plain paragraphs). const MAX_TABLE_DEPTH: usize = 24; @@ -518,7 +520,7 @@ impl Reader<'_> { } } "mc:AlternateContent" => { - if let Some(o) = self.read_ink_alternate(sc, k, rels) { + if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_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) { @@ -687,7 +689,7 @@ impl Reader<'_> { } "w:ruby" => self.read_ruby(sc, pb, k, props), "mc:AlternateContent" => { - if let Some(o) = self.read_ink_alternate(sc, k, rels) { + if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_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() { @@ -1011,6 +1013,29 @@ impl Reader<'_> { Some(object) } + /// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its + /// fallback picture, keeping both branches so saving writes the chart back rather than only + /// the picture. `None` (read the fallback alone) when it can't be kept whole. + fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option { + let choice = ac + .children("mc:Choice") + .find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?; + // A requirement in a namespace we can't name couldn't be written back. + let requires = choice.attr("Requires").unwrap_or(""); + if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") { + return None; + } + let fallback = ac.child("mc:Fallback")?; + let kept = El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] }; + let source = self.keep_embedded(&kept, rels, &[])?; + let Some(InlineObject::Image { media, w, h, alt, float, .. }) = self.read_drawing(sc, fallback.child("w:drawing")?, rels) else { + return None; + }; + let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media }]; + let graphic = Arc::new(Graphic { kind: GraphicKind::Chart, items, w, h, source: Some(source), ..Default::default() }); + Some(InlineObject::Graphic { w, h, alt, float, graphic }) + } + fn read_ink_content_part(&mut self, content: &El, rels: &Rels) -> Option { let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?; let pkg = self.pkg; diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 702c94413..6b7e04e6b 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -544,12 +544,16 @@ impl Writer<'_> { if ink && !direct_ink { Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); } - let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root); + // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. + let alternate = (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")); + let choice = alternate.and_then(|e| e.child("mc:Choice")); + let native = choice.and_then(|e| e.find("a:graphic")); if alternate.is_some() && native.is_none() { return; } + // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. + let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props); @@ -557,24 +561,30 @@ impl Writer<'_> { w.raw(&root.to_xml()); } else { if fallback.is_some() { + let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; w.open("mc:AlternateContent", &[]); - w.open("mc:Choice", &[("Requires", "wpi")]); + w.open("mc:Choice", &[("Requires", requires)]); } - 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(&native.unwrap_or(&root).to_xml()); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); + let z = self.z; + self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root)); if let Some(fallback) = fallback { w.close("mc:Choice"); - w.raw(&fallback.to_xml()); + match picture { + Some(picture) => { + // Both branches are one object: same id and stacking order. + self.z = z; + w.open("mc:Fallback", &[]); + self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture); + w.close("mc:Fallback"); + } + None => w.raw(&fallback.to_xml()), + } w.close("mc:AlternateContent"); } } @@ -1073,6 +1083,17 @@ impl Writer<'_> { } #[allow(clippy::too_many_arguments)] + /// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place. + #[allow(clippy::too_many_arguments)] + fn graphic_frame(&mut self, w: &mut W, float: &Float, (gw, gh): (f32, f32), docpr: &str, name: &str, alt: &str, graphic: &crate::xml::El) { + w.open("w:drawing", &[]); + self.drawing_open(w, float, gw, gh, docpr, name, alt); + w.empty("wp:cNvGraphicFramePr", &[]); + w.raw(&graphic.to_xml()); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + } + fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) { let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0))); if float.wrap == Wrap::Inline { diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 2d4747841..1ebb497a8 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -38,6 +38,16 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("v", "urn:schemas-microsoft-com:vml"), ("o", "urn:schemas-microsoft-com:office:office"), ("w10", "urn:schemas-microsoft-com:office:word"), + // Office 2016 charts (`cx:chart`), and the namespaces their `mc:Choice` requires. + ("cx", "http://schemas.microsoft.com/office/drawing/2014/chartex"), + ("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"), + ("cx2", "http://schemas.microsoft.com/office/drawing/2015/10/21/chartex"), + ("cx3", "http://schemas.microsoft.com/office/drawing/2016/5/9/chartex"), + ("cx4", "http://schemas.microsoft.com/office/drawing/2016/5/10/chartex"), + ("cx5", "http://schemas.microsoft.com/office/drawing/2016/5/11/chartex"), + ("cx6", "http://schemas.microsoft.com/office/drawing/2016/5/12/chartex"), + ("cx7", "http://schemas.microsoft.com/office/drawing/2016/5/13/chartex"), + ("cx8", "http://schemas.microsoft.com/office/drawing/2016/5/14/chartex"), ]; /// The `a:ext` URI under which a drawing is marked decorative ([MS-ODRAWXML]), and the diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 6b46052b7..471a1bebe 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -928,6 +928,88 @@ fn chart_round_trips_with_its_parts() { check_chart(&again, &ids[0]); } +// ---- Office 2016 charts (cx:chart) written back with their fallback picture (#997) ---- + +const CHARTEX_NS: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; +const CHARTEX_XML: &str = r#""#; +const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; + +/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an +/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`. +fn chartex_docx(requires: &str, requires_ns: &str) -> Vec { + let frame = r#""#; + let body = format!( + r#"Spread{frame}{frame}"# + ); + let doc = format!(r#"{body}"#); + let doc_rels = r#""#; + let chart_rels = r#""#; + let types = format!( + r#""# + ); + zip(&[ + ("[Content_Types].xml", types.as_bytes()), + ("_rels/.rels", ROOT_RELS.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/_rels/document.xml.rels", doc_rels.as_bytes()), + ("word/charts/chartEx1.xml", CHARTEX_XML.as_bytes()), + ("word/charts/_rels/chartEx1.xml.rels", chart_rels.as_bytes()), + ("word/charts/colors1.xml", COLORS_XML.as_bytes()), + ("word/embeddings/Microsoft_Excel_Worksheet.xlsx", WORKBOOK.as_bytes()), + ("word/media/image1.png", b"fallback picture bytes"), + ]) +} + +#[test] +fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() { + // Word binds `cx1`… to the chart type's own namespace; Apache POI's sample binds `cx`. + for (requires, requires_ns) in [("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"), ("cx", CHARTEX_NS)] { + let mut d = wordcraft_docx::read(&chartex_docx(requires, requires_ns)).unwrap(); + for _ in 0..2 { + let g = graphics(&d); + let [InlineObject::Graphic { w, graphic, .. }] = g.as_slice() else { panic!("{requires}: {g:?}") }; + assert_eq!(*w, 216.0); + assert!( + graphic.items.iter().any(|i| matches!(i, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media))), + "{requires}: the chart shows its fallback picture" + ); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + let body = text(&files, "word/document.xml"); + assert_eq!(attr_values(&body, "mc:Choice", "Requires"), [requires], "{body}"); + assert!(body.contains(&format!(r#"xmlns:{requires}="{requires_ns}""#)), "{requires} is declared: {body}"); + let doc_rels = text(&files, "word/_rels/document.xml.rels"); + let [cx_id] = attr_values(&body, "cx:chart", "r:id").try_into().unwrap_or_else(|_| panic!("chart lost: {body}")); + let (kind, target) = rel(&doc_rels, &cx_id); + assert!(kind.ends_with("/chartEx"), "{kind}"); + let chart = part_at("word", &target); + assert_eq!(text(&files, &chart), CHARTEX_XML); + let types = text(&files, "[Content_Types].xml"); + assert!(types.contains(&format!(r#"PartName="/{chart}" ContentType="{CT_CHARTEX}""#)), "{types}"); + let (dir, file) = chart.rsplit_once('/').unwrap(); + let (_, wb) = rel(&text(&files, &format!("{dir}/_rels/{file}.rels")), "rId1"); + assert_eq!(files.get(&part_at(dir, &wb)).map(Vec::as_slice), Some(WORKBOOK.as_bytes())); + let fallback = body.split("").nth(1).unwrap_or_else(|| panic!("no fallback: {body}")); + let [pic_id] = attr_values(fallback, "a:blip", "r:embed").try_into().unwrap_or_else(|_| panic!("fallback picture lost: {body}")); + assert_eq!(files.get(&part_at("word", &rel(&doc_rels, &pic_id).1)).map(Vec::as_slice), Some(&b"fallback picture bytes"[..])); + assert!(attr_values(&body, "wp:docPr", "name").iter().all(|n| n.starts_with("Chart ")), "{body}"); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + d = wordcraft_docx::read(&zip(&entries)).unwrap(); + } + } +} + +#[test] +fn resized_office_2016_chart_resizes_its_fallback_too() { + let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap(); + if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) + && let Some(InlineObject::Graphic { w, h, .. }) = p.objects.first_mut() + { + (*w, *h) = (288.0, 144.0); + } + let body = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/document.xml"); + assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"], "{body}"); +} + #[test] fn moved_and_copied_chart_keeps_its_parts() { let d = wordcraft_docx::read(&chart_docx()).unwrap();