Files
craft-libs/crates/xlsx/tests/richdata.rs
T
Dylan M. Taylor 7dcc9950a1 craft-xlsx: pictures in cells and dynamic-array metadata
craft-xlsx: pictures in cells and dynamic-array metadata

* 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).

* craft-xlsx: tables (ListObjects) read and written back

Table mirrors CT_Table: every attribute, columns with calculated and totals formulas, the
style info, autoFilter/sortState/extensions kept as written. Package::tables reads a sheet's
table parts; Document worksheets hold them as TableParts, regenerated with their
relationships and tableParts entries. Part names a document keeps are reserved before new
parts are named, so a new part never takes a name a later sheet keeps.

* craft-xlsx: PivotTables (caches, records, tables) read and written

Typed pivot cache definitions (source, fields, shared items, grouping),
streamed cache records and PivotTable definitions (location, fields and
items, axes, page and data fields, style), with every unmodelled child
and attribute kept. Workbook::pivot_caches lifts pivotCaches. A Document
holds caches and tables and regenerates their parts, relationships and
pivotCaches entries; an OLAP cache or a table on one stays a kept part.
SharedItems::describe gives Excel's shared-item flags and records_xml
writes a records part, after GridCraft's pivot writer.

* craft-xlsx: pictures in cells and dynamic-array metadata

Package::cell_pictures follows a cell's vm through the value and future
metadata to its _localImage rich value and image, bounded and lenient as
GridCraft's richdata.rs reads it. Document::add_cell_metadata writes the
metadata part (XLDAPR for dynamic arrays, XLRICHVALUE for pictures), the
rich-value parts and the images as GridCraft writes them.
2026-11-11 22:44:39 -05:00

103 lines
5.4 KiB
Rust

//! Pictures in cells and dynamic-array metadata: written, read back, and hostile metadata.
use craft_xlsx::testing::workbook;
use craft_xlsx::{CellPicture, Document, Error, Limits, Package};
fn png(w: u32, h: u32, tag: u8) -> Vec<u8> {
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
b.extend(w.to_be_bytes());
b.extend(h.to_be_bytes());
b.extend([8, 6, 0, 0, 0, tag, 0, 0, 0]);
b
}
fn new_doc() -> Document {
Document::read(&workbook(&[("S", r#"<row r="1"><c r="A1" vm="1" t="e"><v>#VALUE!</v></c></row>"#)], &[]), &Limits::default()).unwrap()
}
fn part(doc: &Document, name: &str) -> String {
doc.parts.iter().find(|p| p.name == name).map(|p| String::from_utf8_lossy(&p.bytes).into_owned()).unwrap_or_default()
}
#[test]
fn pictures_round_trip() {
let pictures = vec![
CellPicture { image: png(10, 10, 1), alt: "a & b".into() },
CellPicture { image: png(20, 5, 2), alt: String::new() },
CellPicture { image: png(10, 10, 1), alt: "again".into() },
];
let mut doc = new_doc();
doc.add_cell_metadata(&pictures, true).unwrap();
assert!(matches!(doc.add_cell_metadata(&pictures, false), Err(Error::Invalid(_))));
let back = Document::read(&doc.write().unwrap(), &Limits::default()).unwrap();
assert_eq!(back.cell_pictures, pictures.iter().cloned().map(Some).collect::<Vec<_>>());
assert_eq!(back.warnings, ["no styles part"]);
assert!(back.parts.iter().any(|p| p.name == "xl/media/cellImage1.png"));
let meta = part(&back, "xl/metadata.xml");
assert!(meta.contains(r#"<metadataTypes count="2"><metadataType name="XLDAPR""#) && meta.contains(r#"<rc t="2" v="2"/>"#));
// Kept as read on a second write.
let again = Document::read(&back.write().unwrap(), &Limits::default()).unwrap();
assert_eq!(again.cell_pictures, back.cell_pictures);
}
/// The dynamic-array metadata alone, as GridCraft writes it.
#[test]
fn dynamic_arrays_only() {
let mut doc = new_doc();
doc.add_cell_metadata(&[], true).unwrap();
assert_eq!(
part(&doc, "xl/metadata.xml"),
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<metadata xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:xda=\"http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray\"><metadataTypes count=\"1\"><metadataType name=\"XLDAPR\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\" clearComments=\"1\" assign=\"1\" coerce=\"1\" cellMeta=\"1\"/></metadataTypes><futureMetadata name=\"XLDAPR\" count=\"1\"><bk><extLst><ext uri=\"{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}\"><xda:dynamicArrayProperties fDynamic=\"1\" fCollapsed=\"0\"/></ext></extLst></bk></futureMetadata><cellMetadata count=\"1\"><bk><rc t=\"1\" v=\"0\"/></bk></cellMetadata></metadata>"
);
let bytes = doc.write().unwrap();
let mut p = Package::open(&bytes, &Limits::default()).unwrap();
assert_eq!(p.cell_pictures().unwrap(), Vec::new());
}
/// Rewrites one part of a written workbook.
fn with_part(bytes: &[u8], name: &str, edit: impl Fn(String) -> String) -> Vec<u8> {
let mut doc = Document::read(bytes, &Limits::default()).unwrap();
let p = doc.parts.iter_mut().find(|p| p.name == name).unwrap();
p.bytes = edit(String::from_utf8_lossy(&p.bytes).into_owned()).into_bytes();
doc.write().unwrap()
}
#[test]
fn hostile_metadata_reads_what_it_can() {
let mut doc = new_doc();
doc.add_cell_metadata(&[CellPicture { image: png(4, 4, 0), alt: "x".into() }], false).unwrap();
let good = doc.write().unwrap();
let pictures = |b: &[u8]| {
let mut p = Package::open(b, &Limits::default()).unwrap();
let pics = p.cell_pictures().unwrap();
(pics.iter().map(Option::is_some).collect::<Vec<_>>(), p.warnings().to_vec())
};
assert_eq!(pictures(&good).0, [true]);
// A value block pointing past the future metadata, and one with no rich-value record.
let b = with_part(&good, "xl/metadata.xml", |m| {
m.replace(
r#"<valueMetadata count="1"><bk><rc t="1" v="0"/></bk>"#,
r#"<valueMetadata count="2"><bk><rc t="1" v="9"/></bk><bk><rc t="7" v="0"/></bk>"#,
)
});
assert_eq!(pictures(&b).0, [false, false]);
// Two XLRICHVALUE types: nothing is trusted.
let b = with_part(&good, "xl/metadata.xml", |m| m.replace("</metadataTypes>", r#"<metadataType name="XLRICHVALUE"/></metadataTypes>"#));
let (got, warnings) = pictures(&b);
assert!(got.is_empty() && warnings.iter().any(|w| w.contains("two XLRICHVALUE")));
// A rich value naming a relationship that is not there, and a count that lies.
let b = with_part(&good, "xl/richData/rdrichvalue.xml", |m| m.replace("<v>0</v>", "<v>5</v>").replace(r#"count="1""#, r#"count="3""#));
let (got, warnings) = pictures(&b);
assert_eq!(got, [false]);
assert!(warnings.iter().any(|w| w.contains("count")));
// Not a picture: the image bytes are not PNG.
let b = with_part(&good, "xl/media/cellImage1.png", |_| "GIF89a".into());
assert_eq!(pictures(&b).0, [false]);
// More records than the limit is refused.
let many = "<bk><rc t=\"1\" v=\"0\"/></bk>".repeat(craft_xlsx::MAX_RECORDS + 1);
let b = with_part(&good, "xl/metadata.xml", |m| m.replace(r#"<bk><rc t="1" v="0"/></bk>"#, &many));
let mut p = Package::open(&b, &Limits::default()).unwrap();
assert!(matches!(p.cell_pictures(), Err(Error::TooLarge(_))));
}