diff --git a/Cargo.lock b/Cargo.lock index 0404ebf61..eaed45635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2098,6 +2098,7 @@ dependencies = [ name = "craft-xlsx" version = "0.1.0" dependencies = [ + "craft-drawingml", "craft-opc", "craft-sheet", "craft-xml", diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 68144070a..0d09f79aa 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -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 } diff --git a/crates/xlsx/README.md b/crates/xlsx/README.md index 953a7ee5a..d8ea357d3 100644 --- a/crates/xlsx/README.md +++ b/crates/xlsx/README.md @@ -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. diff --git a/crates/xlsx/src/document.rs b/crates/xlsx/src/document.rs index 859630301..829e242a2 100644 --- a/crates/xlsx/src/document.rs +++ b/crates/xlsx/src/document.rs @@ -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, + /// The drawing part, written from here. `None` removes it. + pub drawing: Option, +} + +/// 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, +} + +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 = 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, 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 { + 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, Error> { @@ -270,7 +388,43 @@ impl Document { fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, taken: &mut Vec, blocks: &mut Vec) { 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); diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs new file mode 100644 index 000000000..2e91edfd3 --- /dev/null +++ b/crates/xlsx/src/drawing.rs @@ -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 }, + /// `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, +} + +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 { + 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)> { + 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, + /// 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 { + 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 { + 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#"1952520409190500 +0000 +"#; + + 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"))); + } +} diff --git a/crates/xlsx/src/features.rs b/crates/xlsx/src/features.rs index 19fd56401..db020ab49 100644 --- a/crates/xlsx/src/features.rs +++ b/crates/xlsx/src/features.rs @@ -649,6 +649,8 @@ pub struct Features { pub hyperlinks: Vec, /// `legacyDrawing/@r:id`: the VML part the sheet's notes and form controls are drawn in. pub legacy_drawing: Option, + /// `drawing/@r:id`: the drawing part (shapes, pictures, charts). + pub drawing: Option, } 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)))), ] } } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 24cce3292..039720687 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -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, diff --git a/crates/xlsx/src/ns.rs b/crates/xlsx/src/ns.rs index 3edf87928..76877a982 100644 --- a/crates/xlsx/src/ns.rs +++ b/crates/xlsx/src/ns.rs @@ -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"), diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index 6fc9dc941..569f1596f 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -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, 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, 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, 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, Error> { self.opc.read(part)?.ok_or_else(|| Error::Invalid(format!("missing part {part}"))) } diff --git a/crates/xlsx/tests/write.rs b/crates/xlsx/tests/write.rs index c42fb522f..2fea93ab0 100644 --- a/crates/xlsx/tests/write.rs +++ b/crates/xlsx/tests/write.rs @@ -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 { let package_rels = format!( r#""# ); - let types = r#""#; + let types = r#""#; zip(&[ ("[Content_Types].xml", types.as_bytes()), ("_rels/.rels", package_rels.as_bytes()), @@ -62,7 +63,10 @@ fn excel_like() -> Vec { ("xl/worksheets/sheet2.xml", format!("").as_bytes()), ("xl/chartsheets/sheet1.xml", b""), ("xl/theme/theme1.xml", b""), - ("xl/drawings/drawing1.xml", b""), + ("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""), @@ -75,6 +79,10 @@ fn excel_like() -> Vec { const COMMENTS: &str = "AnnAnn:\nnote"; const VML: &str = "2, 15, 0, 2, 4, 15, 4, 1611"; +const DRAWING: &str = r#"40109015000200"#; +const DRAWING_RELS: &str = r#""#; +const CHART: &str = r#"S1Data!$A$1:$A$2abData!$C$1:$C$212"#; + 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::>(), [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::>(), [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("