Files
Dylan M. Taylor ae085a2f7e craft-xlsx: write SpreadsheetML workbooks (Document read/write) (#1041)
* 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.
2026-11-11 21:01:51 -05:00
..

craft-opc

Open Packaging Conventions (ECMA-376 Part 2): the ZIP container that DOCX, XLSX and PPTX files share. It replaces the package layer each Craft app carried for its own format (WordCraft's docx::package, GridCraft's xlsx::package, DeckCraft's pptx::opc).

What the parts hold stays with each format's crate. Parts are read with zip and XML with craft-xml.

use craft_opc::{Limits, Package, PackageOut, RelsOut};

let mut out = PackageOut::default();
out.xml_part("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", b"<workbook/>".to_vec());
out.binary("xl/media/image1.png", "image/png", vec![0x89, b'P', b'N', b'G']);
let mut rels = RelsOut::default();
rels.add("officeDocument", "xl/workbook.xml", false);
out.rels("", &rels);
let bytes = out.finish().unwrap();

let limits = Limits::new(10_000, 64 << 20, 256 << 20);
let pkg = Package::open(&bytes, &limits).unwrap();
let main = pkg.rels("").first("officeDocument").map(|r| r.target.clone()).unwrap();
assert_eq!(main, "xl/workbook.xml");
assert_eq!(pkg.content_types().of("xl/media/image1.png"), Some("image/png"));
assert!(pkg.get("XL/Workbook.xml").is_some());

Reading

Two readers, because the apps read in two ways:

Package LazyPackage
Inflates every part, in open a part when read asks for it
Borrows the file's bytes no yes
A part over a limit refuses the package, or is left out (Limits::oversized) is that read's error
A damaged entry left out with a logged warning is that read's error, and ends the budget (read_optional_limited: costs only its own allowance)
Came from WordCraft (refuse), DeckCraft (leave out) GridCraft

Both look parts up without regard to case, leading / or \ for /.

Limits

Limits has no defaults: each format keeps its own numbers.

Limit Checked Error
max_entries from the end records, before zip indexes the entries (check_entry_count), and again on the count zip found TooManyEntries, TooManyEndRecords
max_part on the declared size, then on what is inflated (a read stops one byte past it) PartTooLarge
max_total on the bytes kept PackageTooLarge
max_work on the bytes inflated, kept or not: an entry inflates in full before its checksum is compared, so entries that fail it would otherwise be free PackageTooLarge

Limits::new sets max_work to max_total and refuses oversized parts. At most 16 MiB is reserved for a part before its bytes arrive, whatever size it declares.

check_entry_count is a bytes-only check of what the end of central directory records declare. It does not validate the archive; zip does.

Names and relationships

  • Package::canonical(name) is the stored spelling. When entries differ only in case, they are one part: the spelling a relationship names is kept, else the first in byte order.
  • Rels: by_id, target, first(type), all(type). A type is matched by its full URI or by its last segment, so first("image") finds the transitional and the strict spelling (type_is). The first relationship of a repeated id wins.
  • Rel::target is a part name for an internal target and the URL for an external one; target_raw is what the file wrote. A target is looked up as written, then with %hh escapes decoded; when the package has the part, target is its stored spelling.
  • resolve(dir, target) drops a fragment and cannot climb out of the package. relative(from, to) is its inverse.
  • ContentTypes::of(part): Override by part name, then Default by extension.

A missing or unreadable relationships or content types part reads as empty, with a logged warning for an unreadable one.

Writing

  • PackageOut: xml_part (with an Override), binary (a Default for its extension, or an Override when the extension already has another type), raw, rels; finish writes [Content_Types].xml first and leaves out a second part under a name already written.
  • RelsOut: add reuses the relationship already made for a type, target and mode; add_new never does. Ids are rId1, rId2…
  • ZipOut and zip_entries for a writer that builds its own content types. Output is deterministic: deflate level 6, one fixed timestamp. Names with an already-compressed extension are stored (compression_for).

Not here

  • Flat OPC (pkg:package XML) and encrypted packages (a compound file holding EncryptedPackage): still with the apps.
  • A full ZIP preflight (overlapping entries, lying offsets): check_entry_count covers the entry count only.