mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
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 <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
68501de224
commit
e486f7c39d
@@ -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<InlineObject> {
|
||||
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<InlineObject> {
|
||||
let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?;
|
||||
let pkg = self.pkg;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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#"<cx:chartSpace xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><cx:chartData><cx:externalData r:id="rId1" cx:autoUpdate="0"/></cx:chartData><cx:chart><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="boxWhisker"/></cx:plotAreaRegion></cx:plotArea></cx:chart></cx:chartSpace>"#;
|
||||
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<u8> {
|
||||
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
);
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
|
||||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
|
||||
let types = format!(
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
|
||||
);
|
||||
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("<mc:Fallback>").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();
|
||||
|
||||
Reference in New Issue
Block a user