//! DrawingML spreadsheet drawings (`xl/drawings/drawingN.xml`): anchors, pictures, shapes and //! chart frames. use std::fmt::Write as _; use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; use gridcraft_model::{Anchor, AnchorMode, Color, Image, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor, Sheet, Theme}; use crate::IoError; use crate::read::Ctx; use crate::tables::EMU_PER_PX; use crate::xml::{El, esc, esc_attr}; // ---------------------------------------------------------------- reading fn emu_px(v: Option) -> f32 { v.map(|e| (e as f64 / EMU_PER_PX).clamp(-1e7, 1e7) as f32).unwrap_or(0.0) } fn marker(e: &El) -> (CellRef, f32, f32) { let num = |n: &str| e.child(n).and_then(|c| c.text.trim().parse::().ok()); let col = num("col").unwrap_or(0).clamp(0, MAX_COLS as i64 - 1) as u32; let row = num("row").unwrap_or(0).clamp(0, MAX_ROWS as i64 - 1) as u32; (CellRef::new(row, col), emu_px(num("colOff")).max(0.0), emu_px(num("rowOff")).max(0.0)) } fn ext_px(e: Option<&El>) -> (f32, f32) { match e { Some(x) => (emu_px(x.attr_i64("cx")).max(0.0), emu_px(x.attr_i64("cy")).max(0.0)), None => (0.0, 0.0), } } fn read_anchor(a: &El, sheet: &Sheet) -> Option { match a.name.as_str() { "twoCellAnchor" => { let (cell, dx, dy) = marker(a.child("from")?); let (to, tdx, tdy) = a.child("to").map(marker).unwrap_or((cell, dx, dy)); let x1 = sheet.col_left(cell.col) + dx as f64; let y1 = sheet.row_top(cell.row) + dy as f64; let x2 = sheet.col_left(to.col) + tdx as f64; let y2 = sheet.row_top(to.row) + tdy as f64; // Excel stores "Move and size with cells" as a plain twoCellAnchor, "Move but don't // size" as editAs="oneCell", and "Don't move or size" as editAs="absolute". let mode = match a.attr("editAs") { Some("absolute") => AnchorMode::Absolute, Some("oneCell") => AnchorMode::MoveOnly, _ => AnchorMode::MoveAndSize, }; Some(Anchor { cell, dx, dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode }) } "oneCellAnchor" => { let (cell, dx, dy) = marker(a.child("from")?); let (width, height) = ext_px(a.child("ext")); Some(Anchor { cell, dx, dy, width, height, mode: AnchorMode::MoveOnly }) } "absoluteAnchor" => { let pos = a.child("pos"); let x = emu_px(pos.and_then(|p| p.attr_i64("x"))).max(0.0) as f64; let y = emu_px(pos.and_then(|p| p.attr_i64("y"))).max(0.0) as f64; let col = sheet.col_at(x); let row = sheet.row_at(y); let (width, height) = ext_px(a.child("ext")); Some(Anchor { cell: CellRef::new(row, col), dx: (x - sheet.col_left(col)).max(0.0) as f32, dy: (y - sheet.row_top(row)).max(0.0) as f32, width, height, mode: AnchorMode::Absolute, }) } _ => None, } } /// Colour of a DrawingML colour choice inside `e` (e.g. a `solidFill`). pub fn dml_color(e: &El) -> Option { if let Some(c) = e.child("srgbClr").and_then(|c| c.attr("val")) { return Color::from_hex(c); } if let Some(c) = e.child("sysClr").and_then(|c| c.attr("lastClr")) { return Color::from_hex(c); } if let Some(c) = e.child("schemeClr").and_then(|c| c.attr("val")) { let i = match c { "bg1" | "lt1" => 0, "tx1" | "dk1" => 1, "bg2" | "lt2" => 2, "tx2" | "dk2" => 3, "accent1" => 4, "accent2" => 5, "accent3" => 6, "accent4" => 7, "accent5" => 8, "accent6" => 9, "hlink" => 10, "folHlink" => 11, _ => return None, }; return Some(Color::Theme(i, 0)); } None } fn mime_for(name: &str) -> &'static str { let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); match ext.as_str() { "png" => "image/png", "jpg" | "jpeg" | "jpe" => "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", _ => "application/octet-stream", } } pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(), IoError> { let Some(x) = cx.optional_xml(part)? else { return Ok(()) }; let rels = cx.pkg.rels(part)?; for a in &x.children { let Some(anchor) = read_anchor(a, sheet) else { continue }; // Objects in markup-compatibility blocks: the first choice (chartex frames), else the // fallback. let objs: Vec<&El> = a .children .iter() .flat_map(|o| match o.name.as_str() { "AlternateContent" => o .child("Choice") .filter(|c| c.child("graphicFrame").is_some()) .or_else(|| o.child("Fallback")) .map(|c| c.children.iter().collect()) .unwrap_or_default(), _ => vec![o], }) .collect(); for obj in objs { match obj.name.as_str() { "pic" => { let Some(rid) = obj.path(&["blipFill", "blip"]).and_then(|b| b.attr("embed")) else { continue }; let Some(rel) = rels.iter().find(|r| r.id == rid && !r.external) else { cx.warn("a linked (not embedded) picture was skipped"); continue; }; let target = rel.target.clone(); let Some(data) = cx.pkg.read(&target)? else { cx.warn(format!("missing picture {target}")); continue; }; let alt = obj.path(&["nvPicPr", "cNvPr"]).and_then(|c| c.attr("descr")).unwrap_or("").to_string(); let id = cx.next_object_id(); sheet.images.push(Image { id, anchor, data, mime: mime_for(&target).to_string(), alt }); } "graphicFrame" => { let Some(gd) = obj.path(&["graphic", "graphicData"]) else { continue }; let uri = gd.attr("uri").unwrap_or(""); if let Some(rid) = gd.child("chart").and_then(|c| c.attr("id")) { let Some(rel) = rels.iter().find(|r| r.id == rid) else { continue }; let target = rel.target.clone(); if let Some(cx_xml) = cx.optional_xml(&target)? { let chartex = uri.contains("chartex"); match if chartex { crate::chartex::read_chartex(&cx_xml) } else { crate::chart::read_chart(&cx_xml) } { Some(mut ch) => { ch.id = cx.next_object_id(); ch.anchor = anchor; sheet.charts.push(ch); } None => cx.warn(format!("chart {target} has no supported plot and was skipped")), } } } else if uri.contains("chartex") || uri.contains("chartEx") { cx.warn("a histogram, waterfall, treemap… chart without its chart part was skipped"); } else { cx.warn("an embedded object (SmartArt, OLE…) was skipped"); } } "sp" | "cxnSp" => { if let Some(s) = read_shape(obj, anchor) { let id = cx.next_object_id(); sheet.shapes.push(Shape { id, ..s }); } } "grpSp" => cx.warn("grouped shapes are not supported and were skipped"), _ => {} } } } Ok(()) } fn read_shape(obj: &El, anchor: Anchor) -> Option { let sp = obj.child("spPr")?; let prst = sp.child("prstGeom").and_then(|g| g.attr("prst")).unwrap_or("rect"); let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false)); let ln = sp.child("ln"); let arrow = ln.is_some_and(|l| l.child("tailEnd").or_else(|| l.child("headEnd")).is_some_and(|t| t.attr("type").is_some_and(|v| v != "none"))); let kind = match prst { _ if tx_box => ShapeKind::TextBox, "roundRect" => ShapeKind::RoundedRectangle, "ellipse" => ShapeKind::Ellipse, "triangle" => ShapeKind::Triangle, "line" | "straightConnector1" if arrow => ShapeKind::Arrow, "line" | "straightConnector1" => ShapeKind::Line, "rightArrow" => ShapeKind::Arrow, _ => ShapeKind::Rectangle, }; let fill = sp.child("solidFill").and_then(dml_color).unwrap_or_default(); let line = ln.and_then(|l| l.child("solidFill")).and_then(dml_color).unwrap_or_default(); let text = obj .child("txBody") .map(|tb| { tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::()).collect::>().join("\n") }) .unwrap_or_default(); let mut format = read_text_format(obj.child("txBody")); format.no_line = ln.is_some_and(|l| l.child("noFill").is_some()); Some(Shape { id: 0, kind, anchor, fill, line, text, format }) } /// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's /// alignment (`a:pPr algn`) and the first run's properties (`a:rPr`: size, bold, italic, fill, /// outline, `a:latin` typeface). fn read_text_format(body: Option<&El>) -> ShapeFormat { let mut f = ShapeFormat::default(); let Some(body) = body else { return f }; f.anchor = body.child("bodyPr").and_then(|b| b.attr("anchor")).map(|a| match a { "ctr" => ShapeTextAnchor::Middle, "b" => ShapeTextAnchor::Bottom, _ => ShapeTextAnchor::Top, }); let first_p = body.kids("p").next(); f.align = first_p.and_then(|p| p.child("pPr")).and_then(|p| p.attr("algn")).map(|a| match a { "ctr" => ShapeTextAlign::Center, "r" => ShapeTextAlign::Right, _ => ShapeTextAlign::Left, }); let rpr = body.kids("p").flat_map(|p| p.kids("r")).find_map(|r| r.child("rPr")); if let Some(r) = rpr { // `sz` is in hundredths of a point (100..=400000 in the schema). f.font_size = r.attr_f64("sz").filter(|v| v.is_finite()).map(|v| (v / 100.0).clamp(1.0, 4000.0) as f32); f.bold = r.flag("b", false); f.italic = r.flag("i", false); f.font = r.child("latin").and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty() && !t.starts_with('+')).map(str::to_string); f.text_color = r.child("solidFill").and_then(dml_color); f.text_outline = r.child("ln").and_then(|l| { let c = l.child("solidFill").and_then(dml_color)?; // Width in EMU (12700 per point); Excel's default hairline is 0.75 pt. let w = l.attr_f64("w").filter(|w| w.is_finite()).map_or(0.75, |w| (w / 12700.0).clamp(0.0, 100.0)) as f32; Some((c, w)) }); } f } // ---------------------------------------------------------------- writing /// The opening `` for a model anchor, carrying Excel's `editAs` when the mode /// is not the default: omitted for "Move and size with cells", `editAs="oneCell"` for "Move but /// don't size", `editAs="absolute"` for "Don't move or size". (Excel writes pictures this way; /// the standalone `oneCellAnchor`/`absoluteAnchor` elements are read on import but not written.) /// Returns the opening tag and the matching closing tag. fn anchor_open(sheet: &Sheet, a: &Anchor) -> (String, &'static str) { let px = |v: f64| ((v.max(0.0)) * EMU_PER_PX).round() as i64; let x1 = sheet.col_left(a.cell.col) + a.dx as f64; let y1 = sheet.row_top(a.cell.row) + a.dy as f64; let x2 = x1 + a.width.max(0.0) as f64; let y2 = y1 + a.height.max(0.0) as f64; let tc = sheet.col_at(x2); let tr = sheet.row_at(y2); let tdx = x2 - sheet.col_left(tc); let tdy = y2 - sheet.row_top(tr); let edit = match a.mode { AnchorMode::MoveAndSize => "", AnchorMode::MoveOnly => " editAs=\"oneCell\"", AnchorMode::Absolute => " editAs=\"absolute\"", }; let open = format!( "{}{}{}{}{tc}{}{tr}{}", a.cell.col, px(a.dx as f64), a.cell.row, px(a.dy as f64), px(tdx), px(tdy) ); (open, "") } fn xfrm(a: &Anchor, tag: &str) -> String { let e = |v: f32| ((v.max(0.0) as f64) * EMU_PER_PX).round() as i64; format!("<{tag}>", e(a.width), e(a.height)) } pub fn srgb(c: &Color, theme: &Theme) -> Option { let [r, g, b] = c.resolve(theme)?; Some(format!("")) } /// One drawing object to write. pub enum Obj<'a> { Image(&'a Image, String), Chart(&'a gridcraft_model::Chart, String), Shape(&'a Shape), } /// The drawing part for a sheet's objects (relationship ids already assigned). pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { let mut s = String::from( "\n", ); for (i, o) in objs.iter().enumerate() { let id = i + 2; let close_tag; match o { Obj::Image(img, rid) => { let (open, close) = anchor_open(sheet, &img.anchor); close_tag = close; s.push_str(&open); let _ = write!( s, "{}", id - 1, esc_attr(&img.alt), xfrm(&img.anchor, "a:xfrm") ); } Obj::Chart(ch, rid) if crate::chartex::is_chartex(ch.kind) => { // Chartex frames come with a fallback for readers that don't know them. let (open, close) = anchor_open(sheet, &ch.anchor); close_tag = close; s.push_str(&open); let (prefix, ns) = crate::chartex::requires(ch.kind); let _ = write!( s, "{}{}This chart type can't be shown by this application.", id - 1, xfrm(&ch.anchor, "xdr:xfrm"), xfrm(&ch.anchor, "a:xfrm") ); } Obj::Chart(ch, rid) => { let (open, close) = anchor_open(sheet, &ch.anchor); close_tag = close; s.push_str(&open); let _ = write!( s, "{}", id - 1, xfrm(&ch.anchor, "xdr:xfrm") ); } Obj::Shape(sh) => { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; s.push_str(&open); let (prst, tx) = match sh.kind { ShapeKind::Rectangle => ("rect", false), ShapeKind::RoundedRectangle => ("roundRect", false), ShapeKind::Ellipse => ("ellipse", false), ShapeKind::Triangle => ("triangle", false), ShapeKind::Line | ShapeKind::Arrow => ("line", false), ShapeKind::TextBox => ("rect", true), ShapeKind::Icon | ShapeKind::Ink => ("rect", false), }; let _ = write!( s, "{}", id - 1, if tx { " txBox=\"1\"" } else { "" }, xfrm(&sh.anchor, "a:xfrm") ); if let Some(c) = srgb(&sh.fill, theme) { let _ = write!(s, "{c}"); } let line_c = srgb(&sh.line, theme); if sh.format.no_line { s.push_str(""); } else if line_c.is_some() || sh.kind == ShapeKind::Arrow { s.push_str(""); match line_c { Some(c) => { let _ = write!(s, "{c}"); } None => s.push_str(""), } if sh.kind == ShapeKind::Arrow { s.push_str(""); } s.push_str(""); } s.push_str(""); if !sh.text.is_empty() { let f = &sh.format; let anchor = match f.anchor { Some(ShapeTextAnchor::Top) => " anchor=\"t\"", Some(ShapeTextAnchor::Middle) => " anchor=\"ctr\"", Some(ShapeTextAnchor::Bottom) => " anchor=\"b\"", None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"", None => "", }; let _ = write!(s, ""); let ppr = match f.align { None => "", Some(ShapeTextAlign::Left) => "", Some(ShapeTextAlign::Center) => "", Some(ShapeTextAlign::Right) => "", }; let (rpr_attrs, rpr_body) = run_props(f, theme); for line in sh.text.split('\n') { if line.is_empty() { let _ = write!(s, "{ppr}"); } else if rpr_body.is_empty() { let _ = write!(s, "{ppr}{}", esc(line)); } else { let _ = write!(s, "{ppr}{rpr_body}{}", esc(line)); } } s.push_str(""); } s.push_str(""); } } s.push_str(""); s.push_str(close_tag); } s.push_str(""); s } /// A text run's `a:rPr` attributes (size, bold, italic) and children in schema order (outline, /// fill, typeface). fn run_props(f: &ShapeFormat, theme: &Theme) -> (String, String) { let mut attrs = String::new(); if let Some(sz) = f.font_size.filter(|v| v.is_finite()) { let _ = write!(attrs, " sz=\"{}\"", (sz.clamp(1.0, 4000.0) * 100.0).round() as u32); } if f.bold { attrs.push_str(" b=\"1\""); } if f.italic { attrs.push_str(" i=\"1\""); } let mut body = String::new(); if let Some((c, w)) = &f.text_outline && let Some(c) = srgb(c, theme) { let emu = (w.clamp(0.0, 100.0) * 12700.0).round() as u32; let _ = write!(body, "{c}"); } if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) { let _ = write!(body, "{c}"); } if let Some(font) = &f.font { let _ = write!(body, "", esc(font)); } (attrs, body) } /// File extension for an image MIME type. pub fn image_ext(mime: &str, data: &[u8]) -> &'static str { match mime.to_ascii_lowercase().as_str() { "image/png" => "png", "image/jpeg" | "image/jpg" => "jpeg", "image/gif" => "gif", "image/bmp" => "bmp", "image/tiff" => "tiff", "image/svg+xml" => "svg", "image/x-emf" => "emf", "image/x-wmf" => "wmf", "image/webp" => "webp", _ => { if data.starts_with(b"\x89PNG") { "png" } else if data.starts_with(&[0xFF, 0xD8]) { "jpeg" } else if data.starts_with(b"GIF8") { "gif" } else { "png" } } } } pub fn image_content_type(ext: &str) -> &'static str { match ext { "jpeg" => "image/jpeg", "gif" => "image/gif", "bmp" => "image/bmp", "tiff" => "image/tiff", "svg" => "image/svg+xml", "emf" => "image/x-emf", "wmf" => "image/x-wmf", "webp" => "image/webp", _ => "image/png", } }