Files
craft-libs/crates/opc/README.md
Dylan M. Taylor f46dc936a5 Add craft-opc: the OPC package layer of DOCX, XLSX and PPTX files (#883)
One crate for what WordCraft's docx::package, GridCraft's xlsx::package and
DeckCraft's pptx::opc each carried: bounded ZIP readers (every part up front,
or on demand), part names, relationships, content types and a package writer.

The declared entry count is checked from the end records before the zip crate
indexes the entries. Limits have no defaults: each format passes its own.
2026-11-11 16:41:01 -05:00

94 lines
4.6 KiB
Markdown

# 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`.
```rust
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.