//! WordCraft DOCX reader and writer (ECMA-376 / ISO/IEC 29500 WordprocessingML). //! //! [`read`] turns a `.docx` package into a [`wordcraft_doc::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)] 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 { 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(package: R, max_part: u64) -> Option { 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; } wordcraft_doc::zip_guard::check(&bytes, package::MAX_ENTRIES as u64).ok()?; let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?; let part = zip.by_name("docProps/core.xml").ok()?; let mut xml = Vec::new(); part.take(max_part).read_to_end(&mut xml).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 { 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 { 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 { 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), }