Files
craft-libs/crates/docx/src/lib.rs
T
Dylan M. Taylor e3e31693c7 Add craft-docx: WordCraft's DOCX reader and writer on craft-wordmodel, craft-opc and craft-xml (#1015)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* craft-docx: WordCraft's crates/docx as it is at wordcraft 07f97737

The source, unchanged, so the next commit shows only what the move changes.
This commit does not build on its own (it still names wordcraft-doc and
wordcraft-geom, and is not a workspace member yet).

* craft-docx: on craft-wordmodel, craft-opc, craft-xml and craft-offcrypto

- The package (zip reading, part names, relationships, content types,
  zip writing) is craft-opc's; the module keeps WordCraft's bookkeeping
  (passthrough keys, the embedded-part manifest) and its own Rel/Rels shape.
- Parts are parsed with craft-xml's canonical names (the Strict drawing
  names and mc:Choice/@Requires through its hooks), then given the tree
  shape the reader was written against.
- Encryption and Restrict Editing hashes are craft-offcrypto's; this crate
  maps its errors and the w:documentProtection attributes.
- wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom.

Tests that covered what now lives in craft-opc and craft-offcrypto are left to
those crates; the DIFAT regression is craft-cfb's. Test-only zip use follows
zip 9's fallible names. The attribute-cap test times the read only.
2026-11-11 20:31:40 -05:00

187 lines
7.6 KiB
Rust

//! DOCX reader and writer (ECMA-376 / ISO/IEC 29500 WordprocessingML).
//!
//! [`read`] turns a `.docx` package into a [`craft_wordmodel::Document`]; [`write`] does the
//! reverse. Both are written from the public specification. The reader is lenient (unknown
//! markup is skipped, bad numbers fall back to defaults) and bounded (zip entry sizes, total
//! decompressed size, XML depth and element counts are capped), so hostile input yields an
//! error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// The code came from an app whose lints allow this.
#![allow(clippy::type_complexity)]
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeExamples;
mod bibliography;
mod chart_spec;
mod crypt;
mod custom;
mod flat;
mod fonts;
mod package;
mod read;
mod smart_art;
#[cfg(test)]
mod test_log;
mod units;
mod wordml2003;
mod wordml2003_write;
mod write;
mod xml;
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
pub use chart_spec::{chart_items, chart_xml};
pub use crypt::{
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests,
encrypt_with_spin_count, is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::{EmbeddedFont, embedded_fonts};
pub use read::{AltChunk, ChunkImporter, ReadOptions, read};
pub use smart_art::smart_art_items;
pub use wordml2003_write::write as write_wordml2003;
pub use write::{write, write_as};
/// Largest `.xml` file read as a Word XML document.
pub const MAX_XML_FILE: usize = 1 << 30;
/// Read a Word XML file (`.xml`): a Word XML Document (Flat OPC, the whole package in one XML
/// file) or a Word 2003 XML Document (WordprocessingML 2003), told apart by the root element.
/// Any other XML gives [`DocxError::NotWordXml`].
pub fn read_xml(bytes: &[u8]) -> Result<craft_wordmodel::Document, DocxError> {
if bytes.len() > MAX_XML_FILE {
return Err(DocxError::Limit(format!("XML file of {} bytes", bytes.len())));
}
match sniff_xml(bytes) {
XmlKind::FlatOpc => flat::read(bytes),
XmlKind::WordMl2003 => wordml2003::read(bytes),
XmlKind::Other => Err(DocxError::NotWordXml),
}
}
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
/// directory and at most `max_part` bytes of that part are inflated: for listing templates by
/// name without unpacking the whole package. A package over 64 MiB, or one declaring more entries
/// than `MAX_ENTRIES` (20,000), has none (#3660).
pub fn package_title<R: std::io::Read>(package: R, max_part: u64) -> Option<String> {
use std::io::Read;
const MAX_PACKAGE: u64 = 64 << 20;
let mut bytes = Vec::new();
package.take(MAX_PACKAGE + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() as u64 > MAX_PACKAGE {
return None;
}
let mut pkg = craft_opc::LazyPackage::open(&bytes, &package::LIMITS).ok()?;
let xml = pkg.read_limited("docProps/core.xml", max_part).ok()??;
let root = xml::parse(&xml).ok()?;
let title = root.child("dc:title")?.text();
let title = title.trim();
(!title.is_empty()).then(|| title.to_string())
}
/// What [`DocxError::PasswordRequired`] says.
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
/// What [`DocxError::WrongPassword`] says.
pub const WRONG_PASSWORD: &str = "the password is incorrect";
/// The `a:ext` URI under a Drawing Canvas's `wpc:extLst` that holds its turn and flips as an
/// `a:xfrm` (`rot`, `flipH`, `flipV`). Neither the canvas schema nor `wp:anchor` carries a
/// transform, so this rides in the extension list Word and other readers skip.
pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm";
/// Read a package that may be password-protected ([`is_encrypted`]): it is decrypted with
/// `password` first. An encrypted package without a password gives
/// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`].
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<craft_wordmodel::Document, DocxError> {
read_with(bytes, &ReadOptions { password, ..Default::default() })
}
/// Read a package with options: the password of an encrypted one ([`read_with_password`]) and
/// an importer for `w:altChunk` content in formats other than WordprocessingML (see
/// [`ChunkImporter`]).
pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result<craft_wordmodel::Document, DocxError> {
if is_encrypted(bytes) {
let package = zeroize::Zeroizing::new(decrypt(bytes, opts.password)?);
return read::read_package(&package, opts.chunks);
}
read::read_package(bytes, opts.chunks)
}
/// Which kind of WordprocessingML package to write. The main part's content type differs per
/// kind, and Word refuses a file whose content type doesn't match its extension.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Flavor {
/// `.docx`
#[default]
Document,
/// `.docm`: may carry a VBA project.
MacroDocument,
/// `.dotx`
Template,
/// `.dotm`: may carry a VBA project.
MacroTemplate,
}
impl Flavor {
/// The flavour a file extension (without the dot, any case) names.
pub fn from_ext(ext: &str) -> Option<Flavor> {
match ext.to_ascii_lowercase().as_str() {
"docx" => Some(Flavor::Document),
"docm" => Some(Flavor::MacroDocument),
"dotx" => Some(Flavor::Template),
"dotm" => Some(Flavor::MacroTemplate),
_ => None,
}
}
/// Content type of the main document part.
pub fn main_content_type(self) -> &'static str {
match self {
Flavor::Document => "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
Flavor::MacroDocument => "application/vnd.ms-word.document.macroEnabled.main+xml",
Flavor::Template => "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml",
Flavor::MacroTemplate => "application/vnd.ms-word.template.macroEnabledTemplate.main+xml",
}
}
/// Can this package hold macros?
pub fn macros(self) -> bool {
matches!(self, Flavor::MacroDocument | Flavor::MacroTemplate)
}
}
/// Errors from reading or writing a DOCX package.
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum DocxError {
/// The bytes are not a readable zip archive.
#[error("not a valid zip package: {0}")]
Zip(String),
/// A required part is absent.
#[error("missing part: {0}")]
MissingPart(String),
/// A part is not well-formed XML.
#[error("malformed XML: {0}")]
Xml(String),
/// The input exceeds a safety limit (size, nesting, element count).
#[error("limit exceeded: {0}")]
Limit(String),
/// The package is readable but isn't a WordprocessingML document.
#[error("not a Word document: {0}")]
NotWord(String),
/// An `.xml` file that is neither a Word XML Document (Flat OPC) nor Word 2003 XML.
#[error("not a Word XML document")]
NotWordXml,
/// The package is encrypted and no password was given.
#[error("{}", PASSWORD_REQUIRED)]
PasswordRequired,
/// The password doesn't open the package.
#[error("{}", WRONG_PASSWORD)]
WrongPassword,
/// The package is encrypted in a way WordCraft can't read, or its encryption data is damaged
/// (or encrypting failed).
#[error("encrypted document: {0}")]
Encryption(String),
}