craft-xlsx: drawings, charts and theme on craft-drawingml (#1058)

* craft-xlsx: read parts with canonical prefixes so kept trees can be written back

The part layer read every part by local name, so extLst children (x14:, xr:, xm:),
mc:AlternateContent and r:id lost their namespaces and could not be written back.
Parts are now read with one fixed prefix per namespace (the main namespace unprefixed),
mc:Ignorable and mc:Choice/@Requires are rewritten to match, and ns::declare prepares a
kept tree for writing. craft-xml: a namespace listed with the empty prefix reads unprefixed.

* craft-xlsx: write SpreadsheetML workbooks (Document read and write)

Document holds a whole workbook: the workbook part, shared strings, styles, each worksheet's
rows and the rest of its part, and every other part kept as bytes. write regenerates the
workbook part, worksheets, shared strings and styles from the model, keeps unknown children
in place (schema order for regenerated ones), keeps part names and relationship ids, and
writes kept parts unchanged. Writers: Styles::to_xml and to_el for each table entry,
StringItem::to_el (phonetic runs checked as on reading), SheetPart::to_xml with rows
streamed, Workbook::to_el, XlsxKind. Row and DefinedName keep their other attributes;
CellStyle reads iLevel. craft-opc: LazyPackage::canonical gives a part's stored name.

* craft-xlsx: typed worksheet features and comments, written back

SheetPart::features lifts sheet views, print setup, data validation, conditional formats,
hyperlinks and the legacy drawing into types that mirror their elements (optional attributes,
unnamed ones kept) and writes them in schema order. Comments come from the comments part,
note shapes from the VML drawing; a sheet that gains comments without a VML drawing gets one.
A hyperlink to a new URL gets its own relationship. craft-sheet: Range derives Default.

* craft-xlsx: drawings, charts and the theme on craft-drawingml

Drawing reads a sheet's xdr:wsDr into anchors with typed placements and objects kept as
written; Anchor gives the object's kind, properties, chart or image relationship and xfrm.
Package::drawing, Package::chart (c: and cx: parts through craft-drawingml) and
Package::theme. Document worksheets hold their drawing part, regenerated with its
relationships kept by id; add_chart and add_picture add new chart parts (chartEx behind
mc:AlternateContent) and images with their anchors. More drawing namespaces get prefixes
(a16, asvg, aink, am3d, cx1-cx4, dgm).
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 21:59:45 -05:00
1 parent d70adc01fb
commit 1acdb0dd68
10 files changed
+658 -7

No files matched your search

Generated
+1
View File
@@ -2098,6 +2098,7 @@ dependencies = [
name = "craft-xlsx"
version = "0.1.0"
dependencies = [
"craft-drawingml",
"craft-opc",
"craft-sheet",
"craft-xml",
+1
View File
@@ -11,6 +11,7 @@ repository.workspace = true
workspace = true
[dependencies]
craft-drawingml = { workspace = true }
craft-opc = { workspace = true }
craft-sheet = { workspace = true }
craft-xml = { workspace = true }
+12
View File
@@ -44,6 +44,18 @@ and the note shapes of the VML drawing (`NoteShape`). A sheet that gains comment
drawing gets one (`notes_vml`, in a shape block no kept drawing uses); an existing VML drawing is
kept as read. Extensions in `extLst` are carried through unchanged.
Drawings and charts, on `craft-drawingml`: `Package::drawing` reads a sheet's `xdr:wsDr` into a
`Drawing` of `Anchor`s, each with a typed `Placement` (two-cell, one-cell, absolute, with EMU
offsets) and its object kept as written; `Anchor` names the object's kind, its `cNvPr`, its chart
or image relationship and its `a:xfrm`, and `craft_drawingml::read` reads its fill, line and text.
`mc:AlternateContent` items (Office 2016 charts, ink) are kept in place, and
`Drawing::alternate_anchors` reads the anchors inside them. `Package::chart` reads a chart part
(`c:` or `cx:`) into `craft_drawingml::chart::Chart`; `Package::theme` reads the theme. A
`Document` worksheet holds its `DrawingPart` (regenerated, its relationships kept by id);
`Document::add_chart` and `Document::add_picture` add a chart part written by `craft-drawingml`
(a `chartEx` part behind `mc:AlternateContent` for the Office 2016 types) or an image, with its
anchor. Chart parts and media are kept parts.
The parts this crate writes use the transitional namespaces; a strict file's kept parts stay as
they were. A binary workbook is not written.
+156 -2
View File
@@ -3,6 +3,9 @@
use craft_opc::{ContentTypes, PackageOut, RT_BASE, Rel, RelOut, Rels, RelsOut, relative, rels_path};
use craft_drawingml::chart::Chart;
use crate::drawing::{Anchor, Drawing, Item, Placement};
use crate::features::{Comments, NoteShape, notes_vml};
use crate::sheet::{CellValue, Row, SheetPart};
use crate::strings::StringItem;
@@ -15,6 +18,10 @@ const CT_STRINGS: &str = "application/vnd.openxmlformats-officedocument.spreadsh
const CT_STYLES: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml";
const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml";
const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing";
const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.drawing+xml";
const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx";
/// The relationship type namespace of strict files: kept relationships are written with the
/// transitional one, as the parts this crate writes are.
const RT_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/";
@@ -77,6 +84,41 @@ pub struct Worksheet {
/// How the comments are shown, from the legacy VML drawing. The drawing itself is kept as
/// read; these are used only to draw the comments of a sheet that has no VML drawing.
pub notes: Vec<NoteShape>,
/// The drawing part, written from here. `None` removes it.
pub drawing: Option<DrawingPart>,
}
/// A worksheet's drawing part and its relationships (to charts and pictures, kept with their
/// ids).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DrawingPart {
/// The part's name; a new name is chosen on writing when empty.
pub part: String,
pub drawing: Drawing,
/// `target` is the part name for an internal relationship; the target written is made
/// relative to the drawing part again.
pub rels: Vec<Rel>,
}
impl DrawingPart {
/// Adds a relationship to part `target` (`kind` a last segment of the standard namespace or
/// a full type URI) and returns its id.
pub fn add_rel(&mut self, kind: &str, target: &str) -> String {
let taken: Vec<String> = self.rels.iter().map(|r| r.id.to_ascii_lowercase()).collect();
let id = fresh(&taken, |n| format!("rId{n}"));
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
self.rels.push(Rel { id: id.clone(), ty, target_raw: String::new(), target: target.to_string(), external: false });
id
}
/// The next free `xdr:cNvPr/@id`.
fn next_shape_id(&self) -> u32 {
let ids = self.drawing.items.iter().filter_map(|i| match i {
Item::Anchor(a) => a.props().and_then(|p| p.attr_u32("id")),
Item::Other(e) => e.descendants().filter(|d| d.name == "xdr:cNvPr").filter_map(|d| d.attr_u32("id")).max(),
});
ids.max().unwrap_or(1).saturating_add(1)
}
}
/// A part carried through unchanged: drawings, charts, the theme, document properties, a VBA
@@ -142,7 +184,8 @@ impl Document {
Ok(part) => {
let comments = p.comments(&part)?;
let notes = if comments.is_some() { p.note_shapes(&part)? } else { Vec::new() };
worksheets.push(Some(Worksheet { rows, part, comments, notes }));
let drawing = p.drawing(&part)?.map(|(name, drawing, rels)| DrawingPart { part: name, drawing, rels: rels.list });
worksheets.push(Some(Worksheet { rows, part, comments, notes, drawing }));
}
Err(Error::TooLarge(m)) => return Err(Error::TooLarge(m)),
Err(e) => {
@@ -159,6 +202,10 @@ impl Document {
if ws.comments.is_some() {
made.extend(ws.part.rels.first("comments").filter(|r| !r.external).map(|r| r.target.clone()));
}
if let Some(d) = &ws.drawing {
made.push(d.part.clone());
made.push(rels_path(&d.part));
}
}
made.extend(workbook_rels.list.iter().filter(|r| !r.external && regenerated(r)).map(|r| r.target.clone()));
let opc = p.opc();
@@ -175,6 +222,77 @@ impl Document {
Ok(Document { kind, workbook_part, workbook, workbook_rels, shared_strings, styles, worksheets, parts, warnings: p.warnings().to_vec() })
}
/// Adds a chart to worksheet `sheet` (an index into [`Workbook::sheets`]): a new chart part
/// written by `craft-drawingml` (an Office 2016 `chartEx` part for the chart types that need
/// one, behind an `mc:AlternateContent` so older readers skip it), and an anchor for it in
/// the sheet's drawing, made when the sheet has none.
pub fn add_chart(&mut self, sheet: usize, placement: Placement, chart: &Chart, name: &str) -> Result<(), Error> {
let taken = self.part_names();
let ex = craft_drawingml::chart::write::chartex_part(chart);
let (part, ct, bytes) = match ex {
Some((bytes, _)) => (fresh(&taken, |n| format!("xl/charts/chartEx{n}.xml")), CT_CHARTEX, bytes),
None => (fresh(&taken, |n| format!("xl/charts/chart{n}.xml")), CT_CHART, craft_drawingml::chart::write::chart_xml(chart).0),
};
let d = self.drawing_of(sheet)?;
let id = d.next_shape_id();
if ct == CT_CHARTEX {
let rel = d.add_rel(RT_CHARTEX, &part);
let mut anchor = Anchor::chart(placement, id, name, &rel);
if let Some(data) = anchor.object.child_mut("a:graphic").and_then(|g| g.child_mut("a:graphicData")) {
data.set_attr("uri", "http://schemas.microsoft.com/office/drawing/2014/chartex");
if let Some(c) = data.child_mut("c:chart") {
c.name = "cx:chart".into();
}
}
let choice = craft_xml::El::new("mc:Choice").with_attr("Requires", "cx1").with_child(anchor.to_el());
d.drawing
.items
.push(Item::Other(craft_xml::El::new("mc:AlternateContent").with_child(choice).with_child(craft_xml::El::new("mc:Fallback"))));
} else {
let rel = d.add_rel("chart", &part);
d.drawing.items.push(Item::Anchor(Anchor::chart(placement, id, name, &rel)));
}
self.parts.push(KeptPart { name: part, content_type: Some(ct.into()), bytes });
Ok(())
}
/// Adds a picture to worksheet `sheet`: the image bytes as a new media part (`ext` its file
/// extension: `png`, `jpeg`, `gif`, ...), and an anchor for it in the sheet's drawing.
pub fn add_picture(&mut self, sheet: usize, placement: Placement, image: Vec<u8>, ext: &str, descr: &str) -> Result<(), Error> {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
let ct = match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"svg" => "image/svg+xml",
"emf" => "image/x-emf",
"wmf" => "image/x-wmf",
"webp" => "image/webp",
_ => return Err(Error::Invalid(format!("no image type for .{ext}"))),
};
let part = fresh(&self.part_names(), |n| format!("xl/media/image{n}.{ext}"));
let d = self.drawing_of(sheet)?;
let id = d.next_shape_id();
let rel = d.add_rel("image", &part);
d.drawing.items.push(Item::Anchor(Anchor::picture(placement, id, &format!("Picture {}", id.saturating_sub(1)), descr, &rel)));
self.parts.push(KeptPart { name: part, content_type: Some(ct.into()), bytes: image });
Ok(())
}
/// Every part name in use, in lower case: kept parts and the drawings of worksheets.
fn part_names(&self) -> Vec<String> {
let drawings = self.worksheets.iter().flatten().filter_map(|w| w.drawing.as_ref()).map(|d| d.part.clone());
self.parts.iter().map(|p| p.name.clone()).chain(drawings).map(|n| n.to_ascii_lowercase()).collect()
}
fn drawing_of(&mut self, sheet: usize) -> Result<&mut DrawingPart, Error> {
let ws =
self.worksheets.get_mut(sheet).and_then(Option::as_mut).ok_or_else(|| Error::Invalid(format!("sheet {sheet} is not a worksheet")))?;
Ok(ws.drawing.get_or_insert_with(DrawingPart::default))
}
/// The package. Refused when [`Document::worksheets`] does not match the workbook's sheets,
/// or a sheet without a worksheet has no kept relationship to point at.
pub fn write(&self) -> Result<Vec<u8>, Error> {
@@ -270,7 +388,43 @@ impl Document {
fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, taken: &mut Vec<String>, blocks: &mut Vec<u32>) {
let mut part = ws.part.clone();
let mut rels = RelsOut::default();
rels.items.extend(ws.part.rels.list.iter().filter(|r| ws.comments.is_some() || r.kind() != "comments").map(kept));
// A comments or drawing relationship stays when this writes that part, or the part is kept.
let stays = |r: &&Rel| match r.kind() {
"comments" => ws.comments.is_some(),
"drawing" => ws.drawing.as_ref().map_or(taken.contains(&r.target.to_ascii_lowercase()), |d| d.part.eq_ignore_ascii_case(&r.target)),
_ => true,
};
rels.items.extend(ws.part.rels.list.iter().filter(stays).map(kept));
if ws.drawing.is_none() && !rels.items.iter().any(|r| part.features.drawing.as_deref() == Some(&r.id)) {
part.features.drawing = None;
}
if let Some(d) = &ws.drawing {
let target = if d.part.is_empty() || taken.contains(&d.part.to_ascii_lowercase()) {
fresh(taken, |n| format!("xl/drawings/drawing{n}.xml"))
} else {
d.part.clone()
};
taken.push(target.to_ascii_lowercase());
let id = match ws.part.rels.list.iter().find(|r| r.kind() == "drawing" && r.target.eq_ignore_ascii_case(&target)) {
Some(r) => r.id.clone(),
None => {
let id = fresh_id(&rels);
rels.items.push(RelOut { id: id.clone(), ty: format!("{RT_BASE}drawing"), target: relative(name, &target), external: false });
id
}
};
part.features.drawing = Some(id);
out.xml_part(&target, CT_DRAWING, d.drawing.to_xml());
let mut drels = RelsOut::default();
for r in &d.rels {
let mut o = kept(r);
if !r.external {
o.target = relative(&target, &r.target);
}
drels.items.push(o);
}
out.rels(&target, &drels);
}
for h in &mut part.features.hyperlinks {
let Some(url) = &h.url else { continue };
let ok = h.rel_id.as_deref().and_then(|id| ws.part.rels.by_id(id)).is_some_and(|r| r.external && r.target == *url);
+357
View File
@@ -0,0 +1,357 @@
//! A worksheet's drawing part (`xdr:wsDr`, ECMA-376 Part 1, 20.5): the anchors that place
//! shapes, pictures, charts and groups on the grid.
//!
//! An anchor's placement is typed ([`Placement`]); the object it places stays the tree the file
//! has (`xdr:sp`, `xdr:pic`, `xdr:graphicFrame`, `xdr:grpSp`, `xdr:cxnSp`), so it is written back
//! as it was. [`Anchor`] says what the object is and which relationships it uses, and gives its
//! `a:xfrm`; its fill, line and text are read with `craft_drawingml::read` (`fill_of`, `line`,
//! `text_body`), which match by local name. Charts themselves are separate parts: see
//! [`crate::Package::chart`].
use craft_drawingml::Xfrm;
use craft_xml::El;
/// A cell corner plus an offset into the cell, in EMU (`xdr:from`, `xdr:to`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Marker {
pub col: u32,
pub col_off: i64,
pub row: u32,
pub row_off: i64,
}
impl Marker {
fn read(e: &El) -> Marker {
let n = |k: &str| e.child(format!("xdr:{k}").as_str()).map(|c| c.text().trim().to_string()).unwrap_or_default();
Marker {
col: craft_xml::value::to_u32(&n("col")).unwrap_or(0),
col_off: craft_xml::value::to_i64_lossy(&n("colOff")).unwrap_or(0),
row: craft_xml::value::to_u32(&n("row")).unwrap_or(0),
row_off: craft_xml::value::to_i64_lossy(&n("rowOff")).unwrap_or(0),
}
}
fn to_el(self, name: &str) -> El {
let leaf = |k: &str, v: String| El::new(format!("xdr:{k}")).with_text(v);
El::new(name)
.with_child(leaf("col", self.col.to_string()))
.with_child(leaf("colOff", self.col_off.to_string()))
.with_child(leaf("row", self.row.to_string()))
.with_child(leaf("rowOff", self.row_off.to_string()))
}
}
/// Where an anchor puts its object.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Placement {
/// `xdr:twoCellAnchor`: from one cell corner to another. `edit_as` is `twoCell`, `oneCell`
/// or `absolute`: how the object moves and sizes with the cells.
TwoCell { from: Marker, to: Marker, edit_as: Option<String> },
/// `xdr:oneCellAnchor`: from a cell corner, with a size in EMU.
OneCell { from: Marker, cx: i64, cy: i64 },
/// `xdr:absoluteAnchor`: a position and size in EMU.
Absolute { x: i64, y: i64, cx: i64, cy: i64 },
}
/// What an anchor places.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ObjectKind {
Shape,
Picture,
/// A graphic frame holding a chart (`c:chart`).
Chart,
/// A graphic frame holding something else (a SmartArt diagram, a table).
GraphicFrame,
Group,
Connector,
/// `xdr:contentPart` (ink) or anything else.
Other,
}
/// One anchor of a drawing.
#[derive(Clone, Debug, PartialEq)]
pub struct Anchor {
pub placement: Placement,
/// The object, as written.
pub object: El,
/// `xdr:clientData` (`fLocksWithSheet`, `fPrintsWithSheet`).
pub client_data: Option<El>,
}
fn ext(e: Option<&El>) -> (i64, i64) {
let v = |k: &str| e.and_then(|e| e.attr(k)).and_then(craft_xml::value::to_i64_lossy).unwrap_or(0);
(v("cx"), v("cy"))
}
impl Anchor {
fn read(a: &El) -> Option<Anchor> {
let placement = match a.name.as_str() {
"xdr:twoCellAnchor" => Placement::TwoCell {
from: Marker::read(a.child("xdr:from")?),
to: a.child("xdr:to").map(Marker::read).unwrap_or_default(),
edit_as: a.attr("editAs").map(str::to_string),
},
"xdr:oneCellAnchor" => {
let (cx, cy) = ext(a.child("xdr:ext"));
Placement::OneCell { from: Marker::read(a.child("xdr:from")?), cx, cy }
}
"xdr:absoluteAnchor" => {
let pos = a.child("xdr:pos");
let v = |k: &str| pos.and_then(|p| p.attr(k)).and_then(craft_xml::value::to_i64_lossy).unwrap_or(0);
let (cx, cy) = ext(a.child("xdr:ext"));
Placement::Absolute { x: v("x"), y: v("y"), cx, cy }
}
_ => return None,
};
let object = a.elements().find(|c| !matches!(c.name.as_str(), "xdr:from" | "xdr:to" | "xdr:ext" | "xdr:pos" | "xdr:clientData"))?.clone();
Some(Anchor { placement, object, client_data: a.child("xdr:clientData").cloned() })
}
pub fn to_el(&self) -> El {
let mut e = match &self.placement {
Placement::TwoCell { from, to, edit_as } => {
let mut e = El::new("xdr:twoCellAnchor");
if let Some(v) = edit_as {
e.set_attr("editAs", v);
}
e.with_child(from.to_el("xdr:from")).with_child(to.to_el("xdr:to"))
}
Placement::OneCell { from, cx, cy } => {
El::new("xdr:oneCellAnchor").with_child(from.to_el("xdr:from")).with_child(El::new("xdr:ext").with_attr("cx", cx).with_attr("cy", cy))
}
Placement::Absolute { x, y, cx, cy } => El::new("xdr:absoluteAnchor")
.with_child(El::new("xdr:pos").with_attr("x", x).with_attr("y", y))
.with_child(El::new("xdr:ext").with_attr("cx", cx).with_attr("cy", cy)),
};
e.push(self.object.clone());
e.push(self.client_data.clone().unwrap_or_else(|| El::new("xdr:clientData")));
e
}
pub fn kind(&self) -> ObjectKind {
match self.object.name.as_str() {
"xdr:sp" => ObjectKind::Shape,
"xdr:pic" => ObjectKind::Picture,
"xdr:graphicFrame" if self.chart_rel().is_some() => ObjectKind::Chart,
"xdr:graphicFrame" => ObjectKind::GraphicFrame,
"xdr:grpSp" => ObjectKind::Group,
"xdr:cxnSp" => ObjectKind::Connector,
_ => ObjectKind::Other,
}
}
/// The object's non-visual properties (`xdr:cNvPr`): `id`, `name`, `descr`, `hidden`.
pub fn props(&self) -> Option<&El> {
self.object.elements().find(|c| c.name.starts_with("xdr:nv")).and_then(|nv| nv.child("xdr:cNvPr"))
}
pub fn name(&self) -> Option<&str> {
self.props().and_then(|p| p.attr("name"))
}
/// The relationship id of the chart part a graphic frame shows (`c:chart/@r:id`, or a
/// `cx:chart` of an Office 2016 chart).
pub fn chart_rel(&self) -> Option<&str> {
let data = self.object.path(&["a:graphic", "a:graphicData"])?;
data.child("c:chart").or_else(|| data.child("cx:chart")).and_then(|c| c.attr("r:id"))
}
/// The relationship id of a picture's image (`a:blip/@r:embed`, or `r:link` for a linked one).
pub fn picture_rel(&self) -> Option<&str> {
let blip = self.object.path(&["xdr:blipFill", "a:blip"])?;
blip.attr("r:embed").or_else(|| blip.attr("r:link"))
}
/// The object's `a:xfrm` (`xdr:xfrm` for a graphic frame), in points, with the child space
/// of a group.
pub fn xfrm(&self) -> Option<(Xfrm, Option<Xfrm>)> {
let x = match self.object.name.as_str() {
"xdr:graphicFrame" => self.object.child("xdr:xfrm"),
"xdr:grpSp" => self.object.path(&["xdr:grpSpPr", "a:xfrm"]),
_ => self.object.path(&["xdr:spPr", "a:xfrm"]),
}?;
Some(craft_drawingml::read::xfrm(x))
}
/// A chart in a graphic frame, its part reached through relationship `rel_id`.
pub fn chart(placement: Placement, id: u32, name: &str, rel_id: &str) -> Anchor {
let frame = El::new("xdr:graphicFrame")
.with_attr("macro", "")
.with_child(
El::new("xdr:nvGraphicFramePr")
.with_child(El::new("xdr:cNvPr").with_attr("id", id).with_attr("name", name))
.with_child(El::new("xdr:cNvGraphicFramePr")),
)
.with_child(
El::new("xdr:xfrm")
.with_child(El::new("a:off").with_attr("x", 0).with_attr("y", 0))
.with_child(El::new("a:ext").with_attr("cx", 0).with_attr("cy", 0)),
)
.with_child(
El::new("a:graphic").with_child(
El::new("a:graphicData")
.with_attr("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")
.with_child(El::new("c:chart").with_attr("r:id", rel_id)),
),
);
Anchor { placement, object: frame, client_data: None }
}
/// A picture of the image reached through relationship `rel_id`, stretched over its anchor.
pub fn picture(placement: Placement, id: u32, name: &str, descr: &str, rel_id: &str) -> Anchor {
let mut props = El::new("xdr:cNvPr").with_attr("id", id).with_attr("name", name);
if !descr.is_empty() {
props.set_attr("descr", descr);
}
let (cx, cy) = match &placement {
Placement::OneCell { cx, cy, .. } | Placement::Absolute { cx, cy, .. } => (*cx, *cy),
Placement::TwoCell { .. } => (0, 0),
};
let pic = El::new("xdr:pic")
.with_child(
El::new("xdr:nvPicPr")
.with_child(props)
.with_child(El::new("xdr:cNvPicPr").with_child(El::new("a:picLocks").with_attr("noChangeAspect", 1))),
)
.with_child(
El::new("xdr:blipFill")
.with_child(El::new("a:blip").with_attr("r:embed", rel_id))
.with_child(El::new("a:stretch").with_child(El::new("a:fillRect"))),
)
.with_child(
El::new("xdr:spPr")
.with_child(
El::new("a:xfrm")
.with_child(El::new("a:off").with_attr("x", 0).with_attr("y", 0))
.with_child(El::new("a:ext").with_attr("cx", cx).with_attr("cy", cy)),
)
.with_child(El::new("a:prstGeom").with_attr("prst", "rect").with_child(El::new("a:avLst"))),
);
Anchor { placement, object: pic, client_data: None }
}
}
/// One child of the drawing: an anchor, or what the file has around anchors (an
/// `mc:AlternateContent` holding Office 2010 or 2016 objects).
#[derive(Clone, Debug, PartialEq)]
pub enum Item {
Anchor(Anchor),
Other(El),
}
/// A drawing part.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Drawing {
pub items: Vec<Item>,
/// The root's attributes (`mc:Ignorable`), as written.
pub attrs: Vec<(String, String)>,
}
impl Drawing {
/// A part read with [`crate::ns::CANONICAL`]. An anchor that does not read (no `xdr:from`, no
/// object) is kept as written.
pub fn read(root: &El) -> Drawing {
let items = root
.elements()
.map(|e| match Anchor::read(e) {
Some(a) => Item::Anchor(a),
None => Item::Other(e.clone()),
})
.collect();
Drawing { items, attrs: root.attrs.clone() }
}
/// The anchors outside `mc:AlternateContent` items (see [`Drawing::alternate_anchors`]).
pub fn anchors(&self) -> Vec<&Anchor> {
self.items.iter().filter_map(|i| if let Item::Anchor(a) = i { Some(a) } else { None }).collect()
}
/// The anchors an `mc:AlternateContent` item holds, read as [`Drawing::read`] reads them:
/// from its first choice, or its fallback when it has no choice.
pub fn alternate_anchors(item: &El) -> Vec<Anchor> {
let branch = item.child("mc:Choice").or_else(|| item.child("mc:Fallback"));
branch.map(|b| b.elements().filter_map(Anchor::read).collect()).unwrap_or_default()
}
pub fn to_xml(&self) -> Vec<u8> {
let mut root = El::new("xdr:wsDr");
root.attrs = self.attrs.clone();
for i in &self.items {
root.push(match i {
Item::Anchor(a) => a.to_el(),
Item::Other(e) => e.clone(),
});
}
crate::ns::declare(&mut root);
// The drawing's own namespace has a prefix; the main SpreadsheetML one is not used.
root.attrs.retain(|(k, _)| k != "xmlns");
let mut w = craft_xml::Writer::document();
w.tree(&root);
w.into_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
const DRAWING: &str = r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor editAs="oneCell"><xdr:from><xdr:col>1</xdr:col><xdr:colOff>9525</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>9</xdr:row><xdr:rowOff>190500</xdr:rowOff></xdr:to><xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"><a:extLst><a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"><a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main" id="{1}"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/></a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>
<xdr:oneCellAnchor><xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>0</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:ext cx="12700" cy="25400"/><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="3" name="Box"/><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm rot="5400000"><a:off x="12700" y="0"/><a:ext cx="12700" cy="25400"/></a:xfrm><a:prstGeom prst="rect"/><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></xdr:spPr></xdr:sp><xdr:clientData fLocksWithSheet="0"/></xdr:oneCellAnchor>
<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex" Requires="cx1"><xdr:absoluteAnchor><xdr:pos x="1" y="2"/><xdr:ext cx="3" cy="4"/><xdr:graphicFrame><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Waterfall"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chart xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/></a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:absoluteAnchor></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:wsDr>"#;
fn read(xml: &str) -> El {
craft_xml::Options::canonical(&crate::ns::CANONICAL).parse(xml.as_bytes()).unwrap_or_default()
}
#[test]
fn anchors_read_and_write_back() {
let d = Drawing::read(&read(DRAWING));
let anchors = d.anchors();
assert_eq!(anchors.len(), 2);
let chart = anchors[0];
assert_eq!((chart.kind(), chart.name(), chart.chart_rel()), (ObjectKind::Chart, Some("Chart 1"), Some("rId1")));
assert_eq!(
chart.placement,
Placement::TwoCell {
from: Marker { col: 1, col_off: 9525, row: 2, row_off: 0 },
to: Marker { col: 4, col_off: 0, row: 9, row_off: 190500 },
edit_as: Some("oneCell".into())
}
);
let shape = anchors[1];
assert_eq!(
(shape.kind(), shape.placement.clone()),
(ObjectKind::Shape, Placement::OneCell { from: Marker::default(), cx: 12700, cy: 25400 })
);
let (x, _) = shape.xfrm().unwrap_or_default();
assert_eq!((x.x, x.w, x.h, x.rot), (1.0, 1.0, 2.0, 90.0));
let mut src = craft_drawingml::part::NoSource;
let fill = craft_drawingml::read::fill_of(&mut src, shape.object.child("xdr:spPr").unwrap_or(&El::default()));
assert!(fill.is_some());
let Item::Other(alt) = &d.items[2] else { panic!("{:?}", d.items[2]) };
let ex = Drawing::alternate_anchors(alt);
assert_eq!(
(ex[0].kind(), ex[0].chart_rel(), ex[0].placement.clone()),
(ObjectKind::Chart, Some("rId2"), Placement::Absolute { x: 1, y: 2, cx: 3, cy: 4 })
);
let out = d.to_xml();
let again = Drawing::read(&read(std::str::from_utf8(&out).unwrap_or_default()));
assert_eq!(again, d);
let text = String::from_utf8_lossy(&out);
assert!(text.contains("a16:creationId") && text.contains(r#"Requires="cx1""#) && !text.contains(r#" xmlns="#), "{text}");
}
#[test]
fn new_objects() {
let at = Placement::OneCell { from: Marker { col: 2, ..Marker::default() }, cx: 100, cy: 50 };
let d = Drawing {
items: vec![Item::Anchor(Anchor::chart(at.clone(), 2, "Chart", "rId7")), Item::Anchor(Anchor::picture(at, 3, "Pic", "logo", "rId8"))],
..Drawing::default()
};
let again = Drawing::read(&read(std::str::from_utf8(&d.to_xml()).unwrap_or_default()));
let a = again.anchors();
assert_eq!((a[0].kind(), a[0].chart_rel()), (ObjectKind::Chart, Some("rId7")));
assert_eq!((a[1].kind(), a[1].picture_rel(), a[1].props().and_then(|p| p.attr("descr"))), (ObjectKind::Picture, Some("rId8"), Some("logo")));
}
}
+4
View File
@@ -649,6 +649,8 @@ pub struct Features {
pub hyperlinks: Vec<Hyperlink>,
/// `legacyDrawing/@r:id`: the VML part the sheet's notes and form controls are drawn in.
pub legacy_drawing: Option<String>,
/// `drawing/@r:id`: the drawing part (shapes, pictures, charts).
pub drawing: Option<String>,
}
impl Features {
@@ -674,6 +676,7 @@ impl Features {
.map(|h| h.children("hyperlink").filter_map(|e| Hyperlink::read(e, rels)).collect())
.unwrap_or_default(),
legacy_drawing: root.child("legacyDrawing").and_then(|l| l.attr_or_plain("r:id")).map(str::to_string),
drawing: root.child("drawing").and_then(|l| l.attr_or_plain("r:id")).map(str::to_string),
}
}
@@ -709,6 +712,7 @@ impl Features {
("rowBreaks", Vec::from_iter(breaks_el("rowBreaks", &p.row_breaks))),
("colBreaks", Vec::from_iter(breaks_el("colBreaks", &p.col_breaks))),
("legacyDrawing", Vec::from_iter(self.legacy_drawing.as_ref().map(|id| El::new("legacyDrawing").with_attr("r:id", id)))),
("drawing", Vec::from_iter(self.drawing.as_ref().map(|id| El::new("drawing").with_attr("r:id", id)))),
]
}
}
+4 -1
View File
@@ -50,6 +50,7 @@
//! ```
mod document;
mod drawing;
mod features;
pub mod ns;
mod package;
@@ -62,11 +63,13 @@ mod workbook;
mod write;
mod xlsb;
pub use craft_drawingml;
pub use craft_sheet::{
BorderStyle, CellError, CellRef, DEFAULT_PALETTE, HAlign, MAX_COLS, MAX_ROWS, Pattern, Range, Script, Underline, VAlign, Value, Visibility,
builtin_format, builtin_format_id, quote_sheet, sheet_needs_quotes,
};
pub use document::{Document, KeptPart, Worksheet, XlsxKind};
pub use document::{Document, DrawingPart, KeptPart, Worksheet, XlsxKind};
pub use drawing::{Anchor, Drawing, Item, Marker, ObjectKind, Placement};
pub use features::{
Break, CfRule, Cfvo, ColorScale, Comment, Comments, ConditionalFormatting, DataBar, DataValidation, DataValidations, Features, HeaderFooter,
Hyperlink, IconSet, NoteShape, PageMargins, PageSetup, Pane, Print, PrintOptions, Selection, SheetView, notes_vml, parse_sqref, range_ref,
+9
View File
@@ -59,6 +59,15 @@ pub const NAMESPACES: &[(&str, &str)] = &[
("cx", "http://schemas.microsoft.com/office/drawing/2014/chartex"),
("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"),
("a14", "http://schemas.microsoft.com/office/drawing/2010/main"),
("a16", "http://schemas.microsoft.com/office/drawing/2014/main"),
("asvg", "http://schemas.microsoft.com/office/drawing/2016/SVG/main"),
("aink", "http://schemas.microsoft.com/office/drawing/2016/ink"),
("am3d", "http://schemas.microsoft.com/office/drawing/2017/model3d"),
("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"),
("dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"),
("v", "urn:schemas-microsoft-com:vml"),
("o", "urn:schemas-microsoft-com:office:office"),
("x", "urn:schemas-microsoft-com:office:excel"),
+36
View File
@@ -3,6 +3,10 @@
use craft_opc::{ContentTypes, LazyPackage, Rels};
use craft_xml::{El, Options, UnknownEntity};
use craft_drawingml::chart::Chart;
use craft_drawingml::theme::Theme;
use crate::drawing::Drawing;
use crate::features::{Comments, NoteShape};
use crate::sheet::{Row, RowReader, SheetPart};
use crate::strings::StringItem;
@@ -270,6 +274,38 @@ impl<'a> Package<'a> {
Ok(self.optional_xml(&rel.target.clone())?.map(|v| NoteShape::read_all(&v)).unwrap_or_default())
}
/// The drawing part of a worksheet read by [`Package::sheet`]: its name, the drawing, and its
/// relationships (charts, pictures). `None` when the sheet has none.
pub fn drawing(&mut self, sheet: &SheetPart) -> Result<Option<(String, Drawing, Rels)>, Error> {
let Some(rel) = sheet.rels.first("drawing").filter(|r| !r.external) else { return Ok(None) };
let part = rel.target.clone();
let Some(root) = self.optional_xml(&part)? else { return Ok(None) };
let rels = self.rels(&part)?;
Ok(Some((part, Drawing::read(&root), rels)))
}
/// A chart part (`c:chartSpace`, or `cx:chartSpace` of an Office 2016 chart) through
/// `craft-drawingml`. `None` when the part is missing or holds no chart it can read.
pub fn chart(&mut self, part: &str) -> Result<Option<Chart>, Error> {
let Some(bytes) = self.opc.read(part)? else {
self.warn(format!("missing part {part}"));
return Ok(None);
};
let chart = craft_drawingml::chart::read::read_chart_xml(&bytes).or_else(|| craft_drawingml::chart::chartex::read_chartex_xml(&bytes));
if chart.is_none() {
self.warn(format!("skipped {part}: not a chart that can be read"));
}
Ok(chart)
}
/// The workbook's theme (`a:theme`) through `craft-drawingml`; `None` when it has none.
pub fn theme(&mut self) -> Result<Option<Theme>, Error> {
let rels = self.workbook_rels()?;
let Some(rel) = rels.first("theme").filter(|r| !r.external) else { return Ok(None) };
let root = self.optional_xml(&rel.target.clone())?;
Ok(root.map(|r| craft_drawingml::theme::read_theme(&mut craft_drawingml::part::NoSource, &r)))
}
fn required(&mut self, part: &str) -> Result<Vec<u8>, Error> {
self.opc.read(part)?.ok_or_else(|| Error::Invalid(format!("missing part {part}")))
}
+78 -4
View File
@@ -6,6 +6,7 @@ use craft_xlsx::{
Cell, CellRef, CellValue, DefinedName, Document, Error, Font, KeptPart, Limits, Package, Range, Row, SheetEntry, SheetKind, StringItem, Styles,
Value, Visibility, Workbook, Worksheet, XlsxKind,
};
use craft_xlsx::{Marker, ObjectKind, Placement};
const MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
@@ -50,7 +51,7 @@ fn excel_like() -> Vec<u8> {
let package_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>"#
);
let types = 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="bin" ContentType="application/vnd.ms-office.vbaProject"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.ms-excel.sheet.macroEnabled.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/chartsheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/><Override PartName="/xl/comments1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>"#;
let types = 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="bin" ContentType="application/vnd.ms-office.vbaProject"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.ms-excel.sheet.macroEnabled.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/chartsheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/><Override PartName="/xl/comments1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>"#;
zip(&[
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", package_rels.as_bytes()),
@@ -62,7 +63,10 @@ fn excel_like() -> Vec<u8> {
("xl/worksheets/sheet2.xml", format!("<worksheet {ns}><sheetData/></worksheet>").as_bytes()),
("xl/chartsheets/sheet1.xml", b"<chartsheet/>"),
("xl/theme/theme1.xml", b"<a:theme xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" name=\"Office\"/>"),
("xl/drawings/drawing1.xml", b"<xdr:wsDr xmlns:xdr=\"http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing\"/>"),
("xl/drawings/drawing1.xml", DRAWING.as_bytes()),
("xl/drawings/_rels/drawing1.xml.rels", DRAWING_RELS.as_bytes()),
("xl/charts/chart1.xml", CHART.as_bytes()),
("xl/media/image1.png", b"\x89PNG\r\n\x1a\n"),
("xl/styles.xml", styles.as_bytes()),
("xl/sharedStrings.xml", strings.as_bytes()),
("xl/calcChain.xml", b"<calcChain/>"),
@@ -75,6 +79,10 @@ fn excel_like() -> Vec<u8> {
const COMMENTS: &str = "<comments xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><authors><author>Ann</author></authors><commentList><comment ref=\"B2\" authorId=\"0\" shapeId=\"0\"><text><r><rPr><b/></rPr><t>Ann:</t></r><r><t xml:space=\"preserve\">\nnote</t></r></text></comment><comment ref=\"bad\"/></commentList></comments>";
const VML: &str = "<xml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><o:shapelayout v:ext=\"edit\"><o:idmap v:ext=\"edit\" data=\"1\"/></o:shapelayout><v:shape id=\"_x0000_s1025\" style=\"position:absolute;width:120pt;height:60pt\"><x:ClientData ObjectType=\"Note\"><x:Anchor>2, 15, 0, 2, 4, 15, 4, 16</x:Anchor><x:Row>1</x:Row><x:Column>1</x:Column><x:Visible/></x:ClientData></v:shape></xml>";
const DRAWING: &str = r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><xdr:twoCellAnchor><xdr:from><xdr:col>4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>9</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>15</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to><xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"><a:extLst><a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}"><a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main" id="{A}"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rId1"/></a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor><xdr:oneCellAnchor><xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>20</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:ext cx="952500" cy="476250"/><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="3" name="Picture 2" descr="logo"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:oneCellAnchor></xdr:wsDr>"#;
const DRAWING_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image1.png"/></Relationships>"#;
const CHART: &str = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:order val="0"/><c:tx><c:v>S1</c:v></c:tx><c:cat><c:strRef><c:f>Data!$A$1:$A$2</c:f><c:strCache><c:ptCount val="2"/><c:pt idx="0"><c:v>a</c:v></c:pt><c:pt idx="1"><c:v>b</c:v></c:pt></c:strCache></c:strRef></c:cat><c:val><c:numRef><c:f>Data!$C$1:$C$2</c:f><c:numCache><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
fn read(bytes: &[u8]) -> Document {
Document::read(bytes, &Limits::default()).unwrap()
}
@@ -121,6 +129,7 @@ fn assert_same(a: &Document, b: &Document) {
assert_eq!(x.part.rels.list, y.part.rels.list);
assert_eq!(x.part.features, y.part.features);
assert_eq!(x.notes, y.notes);
assert_eq!(x.drawing, y.drawing);
assert_eq!(
x.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml())),
y.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml()))
@@ -139,8 +148,7 @@ fn a_workbook_round_trips() {
assert_eq!(doc.kind, XlsxKind::MacroWorkbook);
assert_eq!(doc.worksheets.iter().map(Option::is_some).collect::<Vec<_>>(), [true, false, true]);
let kept: Vec<&str> = doc.parts.iter().map(|p| p.name.as_str()).collect();
for name in
["_rels/.rels", "docProps/core.xml", "xl/theme/theme1.xml", "xl/drawings/drawing1.xml", "xl/chartsheets/sheet1.xml", "xl/vbaProject.bin"]
for name in ["_rels/.rels", "docProps/core.xml", "xl/theme/theme1.xml", "xl/charts/chart1.xml", "xl/chartsheets/sheet1.xml", "xl/vbaProject.bin"]
{
assert!(kept.contains(&name), "{name} in {kept:?}");
}
@@ -299,6 +307,72 @@ fn doc_after(doc: &Document) -> Document {
read(&doc.write().unwrap())
}
#[test]
fn charts_and_pictures() {
let bytes = excel_like();
let mut doc = read(&bytes);
let d = doc.worksheets[0].as_ref().unwrap().drawing.clone().unwrap();
assert_eq!(d.part, "xl/drawings/drawing1.xml");
let anchors = d.drawing.anchors();
assert_eq!(anchors.iter().map(|a| a.kind()).collect::<Vec<_>>(), [ObjectKind::Chart, ObjectKind::Picture]);
assert_eq!((anchors[1].picture_rel(), anchors[1].name()), (Some("rId2"), Some("Picture 2")));
let chart_part = d.rels.iter().find(|r| Some(r.id.as_str()) == anchors[0].chart_rel()).map(|r| r.target.clone()).unwrap();
assert!(!doc.parts.iter().any(|p| p.name.starts_with("xl/drawings/drawing1")), "the drawing is not a kept part");
let mut p = Package::open(&bytes, &Limits::default()).unwrap();
let chart = p.chart(&chart_part).unwrap().unwrap();
assert_eq!(
(chart.series.len(), chart.series[0].values.clone(), chart.categories.clone()),
(1, vec![Some(1.0), Some(2.0)], vec!["a".to_string(), "b".to_string()])
);
let theme = p.theme().unwrap().unwrap();
assert_eq!(theme.name, "Office");
// A chart and a picture on the sheet without a drawing.
let at = Placement::OneCell { from: Marker { col: 1, row: 1, ..Marker::default() }, cx: 3_000_000, cy: 2_000_000 };
let mut fresh = chart.clone();
fresh.raw = None;
doc.add_chart(2, at.clone(), &fresh, "Chart 9").unwrap();
let waterfall = craft_xlsx::craft_drawingml::chart::Chart { kind: craft_xlsx::craft_drawingml::chart::ChartType::Waterfall, ..fresh.clone() };
doc.add_chart(2, at.clone(), &waterfall, "Waterfall").unwrap();
doc.add_picture(2, at, b"GIF89a".to_vec(), "gif", "small").unwrap();
assert!(
doc.add_picture(1, Placement::Absolute { x: 0, y: 0, cx: 1, cy: 1 }, vec![], "png", "").is_err(),
"a chart sheet has no drawing of its own here"
);
let out = doc.write().unwrap();
let again = read(&out);
assert_same(&doc_after(&doc), &again);
let d2 = again.worksheets[2].as_ref().unwrap().drawing.clone().unwrap();
assert_eq!(d2.part, "xl/drawings/drawing2.xml");
let kinds: Vec<_> = d2.drawing.anchors().iter().map(|a| (a.kind(), a.props().and_then(|p| p.attr_u32("id")))).collect();
assert_eq!(kinds, [(ObjectKind::Chart, Some(2)), (ObjectKind::Picture, Some(4))]);
let alternate: Vec<_> = d2.drawing.items.iter().filter_map(|i| if let craft_xlsx::Item::Other(e) = i { Some(e) } else { None }).collect();
let ex = craft_xlsx::Drawing::alternate_anchors(alternate[0]);
assert_eq!((ex[0].kind(), ex[0].name()), (ObjectKind::Chart, Some("Waterfall")));
let targets: Vec<&str> = d2.rels.iter().map(|r| r.target.as_str()).collect();
assert_eq!(targets, ["xl/charts/chart2.xml", "xl/charts/chartEx1.xml", "xl/media/image1.gif"]);
let mut p = Package::open(&out, &Limits::default()).unwrap();
assert_eq!(p.chart("xl/charts/chart2.xml").unwrap().map(|c| c.series.len()), Some(1));
assert_eq!(p.chart("xl/charts/chartEx1.xml").unwrap().map(|c| c.kind), Some(craft_xlsx::craft_drawingml::chart::ChartType::Waterfall));
let drawing = String::from_utf8(p.opc().read("xl/drawings/drawing2.xml").unwrap().unwrap()).unwrap();
assert!(drawing.contains(r#"Requires="cx1""#) && drawing.contains("xmlns:cx1=") && drawing.contains("<cx:chart r:id="), "{drawing}");
let types = String::from_utf8(p.opc().read("[Content_Types].xml").unwrap().unwrap()).unwrap();
assert!(types.contains(r#"Extension="gif" ContentType="image/gif""#) && types.contains("/xl/drawings/drawing2.xml"), "{types}");
let wb = p.workbook().unwrap();
let sheet = p.sheet(&wb.sheets[2], |_| Ok(())).unwrap();
assert!(sheet.features.drawing.as_deref().is_some_and(|id| sheet.rels.by_id(id).is_some_and(|r| r.target == "xl/drawings/drawing2.xml")));
// A drawing removed takes its relationship with it; its charts stay as kept parts.
let mut doc = again;
doc.worksheets[2].as_mut().unwrap().drawing = None;
let out = doc.write().unwrap();
let mut p = Package::open(&out, &Limits::default()).unwrap();
let wb = p.workbook().unwrap();
let sheet = p.sheet(&wb.sheets[2], |_| Ok(())).unwrap();
assert!(sheet.rels.first("drawing").is_none() && sheet.features.drawing.is_none());
}
#[test]
fn a_new_workbook() {
let cell = |at: &str, value| Cell { at: CellRef::parse(at).unwrap(), value, ..Cell::default() };