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();