//! Writing: a workbook read, written and read again is the same workbook, and every part this //! crate does not read comes back byte for byte. use craft_xlsx::testing::zip; 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"; /// A workbook as Excel saves one: extensions, markup compatibility, a drawing with its own /// relationships, a chart sheet, document properties and a macro project. fn excel_like() -> Vec { let ns = format!( r#"xmlns="{MAIN}" xmlns:r="{RT}" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac" xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision" mc:Ignorable="x14ac xr""# ); let book = format!( r#" Data!$A$1:$C$3 "# ); let book_rels = format!( r#""# ); let sheet1 = format!( r#" 010.1SUM(C1:C3)0.300000000000000041C1*20.2#N/A inline 0.4 0.25"a,b"&C&P x Data!C1:C3D1"# ); let sheet1_rels = format!( r#""# ); let styles = format!( r#" "# ); let strings = format!( r#"Name東京 x_x000D_トウキョウ"# ); let package_rels = format!( r#""# ); let types = r#""#; zip(&[ ("[Content_Types].xml", types.as_bytes()), ("_rels/.rels", package_rels.as_bytes()), ("docProps/core.xml", b""), ("xl/workbook.xml", book.as_bytes()), ("xl/_rels/workbook.xml.rels", book_rels.as_bytes()), ("xl/worksheets/sheet1.xml", sheet1.as_bytes()), ("xl/worksheets/_rels/sheet1.xml.rels", sheet1_rels.as_bytes()), ("xl/worksheets/sheet2.xml", format!("").as_bytes()), ("xl/chartsheets/sheet1.xml", b""), ("xl/theme/theme1.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""), ("xl/comments1.xml", COMMENTS.as_bytes()), ("xl/tables/table1.xml", TABLE.as_bytes()), ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), ("xl/vbaProject.bin", &[0xD0, 0xCF, 0x11, 0xE0, 1, 2, 3]), ]) } 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"#; const TABLE: &str = r#"Prices[[#This Row],[Flag]]*2
"#; fn read(bytes: &[u8]) -> Document { Document::read(bytes, &Limits::default()).unwrap() } /// Everything that should survive a round trip: the fields, and what each regenerated part is /// written as (a kept tree reads back without what writing drops, such as a choice that needs a /// namespace this crate does not know). fn assert_same(a: &Document, b: &Document) { let written = |d: &Document| { let mut root = d.workbook.to_el(); craft_xlsx::ns::declare(&mut root); root }; assert_eq!(a.kind, b.kind); assert_eq!( (&a.workbook.sheets, &a.workbook.defined_names, a.workbook.date_1904, a.workbook.active_tab), (&b.workbook.sheets, &b.workbook.defined_names, b.workbook.date_1904, b.workbook.active_tab) ); assert_eq!(written(a), written(b)); assert_eq!(a.shared_strings, b.shared_strings); if let (Some(x), Some(y)) = (&a.styles, &b.styles) { assert_eq!(x.num_fmts, y.num_fmts); assert_eq!(x.fonts, y.fonts); assert_eq!(x.fills, y.fills); assert_eq!(x.borders, y.borders); assert_eq!(x.cell_style_xfs.iter().map(|x| x.to_el()).collect::>(), y.cell_style_xfs.iter().map(|x| x.to_el()).collect::>()); assert_eq!(x.cell_xfs.iter().map(|x| x.to_el()).collect::>(), y.cell_xfs.iter().map(|x| x.to_el()).collect::>()); assert_eq!(x.cell_styles, y.cell_styles); assert_eq!(x.dxfs, y.dxfs); assert_eq!(x.indexed_colors, y.indexed_colors); assert_eq!(String::from_utf8(x.to_xml()), String::from_utf8(y.to_xml())); } else { assert!(a.styles.is_none() && b.styles.is_none()); } assert_eq!(a.worksheets.len(), b.worksheets.len()); for (x, y) in a.worksheets.iter().zip(&b.worksheets) { let (Some(x), Some(y)) = (x, y) else { assert!(x.is_none() && y.is_none()); continue; }; assert_eq!(x.rows, y.rows); assert_eq!((&x.part.part, &x.part.columns, &x.part.merges), (&y.part.part, &y.part.columns, &y.part.merges)); assert_eq!(String::from_utf8(x.part.to_xml(&x.rows)), String::from_utf8(y.part.to_xml(&y.rows))); 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.tables, y.tables); 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())) ); } let mut pa = a.parts.clone(); let mut pb = b.parts.clone(); pa.sort_by(|x, y| x.name.cmp(&y.name)); pb.sort_by(|x, y| x.name.cmp(&y.name)); assert_eq!(pa, pb); } #[test] fn a_workbook_round_trips() { let doc = read(&excel_like()); 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/charts/chart1.xml", "xl/chartsheets/sheet1.xml", "xl/vbaProject.bin"] { assert!(kept.contains(&name), "{name} in {kept:?}"); } assert!( !kept.iter().any(|n| n.contains("calcChain") || n.contains("styles") || n.contains("sharedStrings") || n.contains("worksheets")), "{kept:?}" ); let bytes = doc.write().unwrap(); let again = read(&bytes); assert_same(&doc, &again); // Written again, the bytes are the same. assert_eq!(again.write().unwrap(), bytes); // What the reader sees, through the part layer. let mut p = Package::open(&bytes, &Limits::default()).unwrap(); assert!(p.opc().read("xl/calcChain.xml").unwrap().is_none()); let wb = p.workbook().unwrap(); assert_eq!(wb.active_tab, Some(1)); assert_eq!(wb.defined_names[1].attrs, [("function".to_string(), "1".to_string()), ("vbProcedure".to_string(), "1".to_string())]); assert!(wb.root.child("mc:AlternateContent").is_none(), "a choice requiring an unlisted namespace is dropped"); assert!(wb.root.find("x15:workbookPr").is_some()); let strings = p.shared_strings().unwrap(); let mut values = vec![]; p.sheet(&wb.sheets[0], |row| { values.extend(row.cells.into_iter().map(|c| (c.at.a1(), c.value.resolve(&strings, false)))); Ok(()) }) .unwrap(); assert_eq!(values[1], ("B1".to_string(), Value::text("東京 x\r"))); assert_eq!(values[7], ("B3".to_string(), Value::text(" inline "))); let book = String::from_utf8(p.opc().read("xl/workbook.xml").unwrap().unwrap()).unwrap(); assert!(book.contains(r#""#), "{book}"); let sheet = String::from_utf8(p.opc().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); assert!(sheet.contains(r#"mc:Ignorable="x14ac xr""#) && sheet.contains(r#""#), "{sheet}"); let types = String::from_utf8(p.opc().read("[Content_Types].xml").unwrap().unwrap()).unwrap(); assert!(types.contains("macroEnabled.main+xml") && types.contains(r#"Extension="bin""#) && !types.contains("calcChain"), "{types}"); } #[test] fn features_are_read() { let doc = read(&excel_like()); let ws = doc.worksheets[0].as_ref().unwrap(); let f = &ws.part.features; assert_eq!(f.views[0].pane.as_ref().and_then(|p| p.state.as_deref()), Some("frozen")); assert_eq!(f.views[0].tab_selected, Some(true)); let dv = &f.validations.as_ref().unwrap().items[0]; assert_eq!((dv.kind.as_deref(), dv.formula1.as_deref(), dv.sqref.len()), (Some("list"), Some("\"a,b\""), 2)); assert_eq!(dv.attrs, [("xr:uid".to_string(), "{00000000-0002-0000-0000-000000000000}".to_string())]); assert_eq!(craft_xlsx::range_ref(&dv.sqref[1]), "C:C"); assert_eq!(f.conditional_formats[0].rules[0].formulas, ["0.25"]); assert_eq!(f.hyperlinks[0].url.as_deref(), Some("https://example.com/a?b&c")); assert_eq!((f.hyperlinks[1].location.as_deref(), f.hyperlinks[1].url.as_deref()), (Some("Data!A1"), None)); assert_eq!(f.print.options.as_ref().and_then(|o| o.grid_lines), Some(true)); assert_eq!(f.print.setup.as_ref().and_then(|s| s.orientation.as_deref()), Some("landscape")); let hf = f.print.header_footer.as_ref().unwrap(); assert_eq!((hf.odd_header.as_deref(), hf.first_footer.as_deref(), hf.different_first), (Some("&C&P"), Some(" x "), Some(true))); assert_eq!((f.print.row_breaks[0].id, f.print.row_breaks[0].man), (Some(10), Some(true))); assert_eq!(f.legacy_drawing.as_deref(), Some("rId3")); let comments = ws.comments.as_ref().unwrap(); assert_eq!(comments.comments.len(), 1); assert_eq!((comments.author(&comments.comments[0]), &*comments.comments[0].text.text), (Some("Ann"), "Ann:\nnote")); assert_eq!( ws.notes, [craft_xlsx::NoteShape { at: CellRef::new(1, 1), visible: true, anchor: Some([2, 15, 0, 2, 4, 15, 4, 16]), size_pt: Some((120.0, 60.0)) }] ); assert!(doc.parts.iter().any(|p| p.name == "xl/drawings/vmlDrawing1.vml") && !doc.parts.iter().any(|p| p.name == "xl/comments1.xml")); } #[test] fn edits_are_written() { let mut doc = read(&excel_like()); let ws = doc.worksheets[0].as_mut().unwrap(); ws.rows.push(Row { index: 9, cells: vec![Cell { at: CellRef::new(9, 3), value: CellValue::Shared(2), ..Cell::default() }], ..Row::default() }); ws.part.merges.push(Range::parse("E1:F1").unwrap()); doc.shared_strings.push(StringItem::plain("new")); doc.workbook.sheets[2].visibility = Visibility::Visible; doc.workbook.date_1904 = true; doc.styles.as_mut().unwrap().fonts[0].italic = Some(true); let again = read(&doc.write().unwrap()); assert_same(&doc, &again); let ws = again.worksheets[0].as_ref().unwrap(); assert_eq!(ws.part.root.child("dimension").and_then(|d| d.attr("ref")), Some("A1:D10")); assert_eq!(ws.part.root.child("mergeCells").and_then(|m| m.attr("count")), Some("2")); // A sheet removed takes its relationship with it. doc.workbook.sheets.remove(2); doc.worksheets.remove(2); let bytes = doc.write().unwrap(); let mut p = Package::open(&bytes, &Limits::default()).unwrap(); let rels = p.workbook_rels().unwrap(); assert!(!rels.list.iter().any(|r| r.target.contains("sheet2"))); assert!(p.opc().read("xl/worksheets/sheet2.xml").unwrap().is_none()); assert_eq!(rels.all("worksheet").count(), 1); } #[test] fn features_are_edited() { let mut doc = read(&excel_like()); let ws = doc.worksheets[0].as_mut().unwrap(); let f = &mut ws.part.features; f.hyperlinks[0].url = Some("https://example.org/".into()); f.conditional_formats.push(craft_xlsx::ConditionalFormatting { sqref: vec![Range::parse("A1:A9").unwrap()], rules: vec![craft_xlsx::CfRule { kind: Some("expression".into()), priority: Some(2), formulas: vec!["A1>1".into()], ..Default::default() }], ..Default::default() }); f.print.setup = None; // A comment on a sheet without a VML drawing gets one, in a shape block no other drawing uses. let empty = doc.worksheets[2].as_mut().unwrap(); empty.comments = Some(craft_xlsx::Comments { authors: vec!["Bo".into()], comments: vec![craft_xlsx::Comment { at: CellRef::new(0, 0), author_id: Some(0), text: StringItem::plain("hi"), ..Default::default() }], ..Default::default() }); let bytes = doc.write().unwrap(); let again = read(&bytes); assert_same(&doc_after(&doc), &again); let f = &again.worksheets[0].as_ref().unwrap().part.features; assert_eq!(f.hyperlinks[0].url.as_deref(), Some("https://example.org/")); assert_eq!(f.conditional_formats.len(), 2); assert!(f.print.setup.is_none()); let sheet2 = again.worksheets[2].as_ref().unwrap(); assert_eq!(sheet2.comments.as_ref().map(|c| c.comments[0].text.text.to_string()), Some("hi".into())); assert!(sheet2.part.features.legacy_drawing.is_some()); assert_eq!(sheet2.notes.iter().map(|n| (n.at, n.visible)).collect::>(), [(CellRef::new(0, 0), false)]); let vml = again.parts.iter().find(|p| p.name == "xl/drawings/vmlDrawing2.vml").unwrap(); assert!(String::from_utf8_lossy(&vml.bytes).contains(r#"data="2""#)); let sheet = String::from_utf8(Package::open(&bytes, &Limits::default()).unwrap().opc().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); let order = [ " = order.iter().map(|o| sheet.find(o).unwrap()).collect(); assert!(at.windows(2).all(|w| w[0] < w[1]), "{sheet}"); // Comments removed take their part and relationship with them. let mut doc = again; doc.worksheets[0].as_mut().unwrap().comments = None; let bytes = doc.write().unwrap(); let mut p = Package::open(&bytes, &Limits::default()).unwrap(); assert!(p.opc().read("xl/comments1.xml").unwrap().is_none()); let wb = p.workbook().unwrap(); let part = p.sheet(&wb.sheets[0], |_| Ok(())).unwrap(); assert!(part.rels.first("comments").is_none()); } /// The document as written: a link to a new URL has a relationship of its own, a new comments /// part a VML drawing. 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(">(); assert_eq!((names(0), names(2)), (vec!["xl/tables/table2.xml".to_string()], vec!["xl/tables/table1.xml".to_string()])); } #[test] fn a_new_workbook() { let cell = |at: &str, value| Cell { at: CellRef::parse(at).unwrap(), value, ..Cell::default() }; let doc = Document { workbook: Workbook { sheets: vec![SheetEntry { name: "Sheet1".into(), sheet_id: 1, visibility: Visibility::Visible, rel_id: String::new(), kind: SheetKind::Worksheet, part: None, }], defined_names: vec![DefinedName { name: "Two".into(), formula: "Sheet1!$B$1".into(), ..DefinedName::default() }], ..Workbook::default() }, shared_strings: vec![StringItem::plain("hello")], styles: Some(Styles { fonts: vec![Font { size: Some(11.0), name: Some("Calibri".into()), ..Font::default() }], ..Styles::default() }), worksheets: vec![Some(Worksheet { rows: vec![Row { index: 0, cells: vec![cell("A1", CellValue::Shared(0)), cell("B1", CellValue::Number(2.0))], ..Row::default() }], ..Worksheet::default() })], ..Document::default() }; let bytes = doc.write().unwrap(); let mut p = Package::open(&bytes, &Limits::default()).unwrap(); assert_eq!(p.workbook_part(), "xl/workbook.xml"); let wb = p.workbook().unwrap(); assert_eq!((wb.sheets[0].part.as_deref(), wb.sheets[0].rel_id.as_str()), (Some("xl/worksheets/sheet1.xml"), "rId1")); assert_eq!(wb.defined_names[0].formula, "Sheet1!$B$1"); let strings = p.shared_strings().unwrap(); let mut values = vec![]; p.sheet(&wb.sheets[0], |row| { values.extend(row.cells.into_iter().map(|c| c.value.resolve(&strings, false))); Ok(()) }) .unwrap(); assert_eq!(values, [Value::text("hello"), Value::Number(2.0)]); assert_eq!(p.styles().unwrap().unwrap().fonts.len(), 1); assert_eq!(read(&bytes).parts.iter().map(|p: &KeptPart| p.name.as_str()).collect::>(), ["_rels/.rels"]); } #[test] fn what_cannot_be_written_is_refused() { let mut doc = read(&excel_like()); doc.worksheets.pop(); assert!(matches!(doc.write(), Err(Error::Invalid(_)))); let mut doc = read(&excel_like()); doc.workbook.sheets[1].rel_id = "nowhere".into(); assert!(matches!(doc.write(), Err(Error::Invalid(_)))); }