Files
wordcraft/crates/docx/src/lib.rs
T
PanyangYandBrandon Thomas de8a81c1e4 References: Word sources file import in Manage Sources (#2640) (#2897)
* References: Word sources file import in Manage Sources (#2640)

Manage Sources gains Import… and references.sources an "import": path
parameter: it reads a b:Sources document (Word's own Sources.xml) into the
document's sources with the same reader as a document's own bibliography
part. A tag already here is left alone, an untagged entry is matched by
title and author or gets a tag like a new source, the whole import is one
undo step, and a file that isn't b:Sources is refused. Desktop only; the
web build hides the button.

The docx reader now shares the b:Sources parser (crates/docx/src/
bibliography.rs) with the import, with the same dedupe and 10,000-source
cap; the hostile-input cap test still passes.

Tests: docx unit tests (names, kinds, accessed/DOI fallbacks, repeated
tags, empty entries, wrong root), engine import test (add, undo, second
import, untagged matching, refused file), ui tests (the dialog button asks
for a "sources" file; finish_pick imports), wordcraft-cli end to end
(import, cite, bibliography, save, reopen; the package's customXml
carries the sources). The button's label is in every interface catalogue.

ROADMAP progress log and docs/roadmap.md, gaps.md, file-format-parity.md
and target-app-parity.md get their rows.

* References: review fixes for the sources import (#2640)

- read_sources validates the root namespace instead of the local name alone:
  an un-namespaced or unrelated <Sources> is refused, and both bibliography
  namespaces read — OOXML's and Word's own 2004/10 (the VBA Source.XML
  format), the latter mapped onto the canonical b prefix with parse_ns.
  Tests: the 2004/10 namespace is accepted; a no-namespace or unrelated
  root is refused.
- The import loop builds the tag and (title, author-or-editor) sets once and
  keeps a per-stem counter, with the stem rule shared with auto_tag
  (auto_tag_base). Importing thousands of untagged sources no longer costs
  O(n^3). Test: untagged sources sharing a stem get Riv21, Riv211, Src, and
  a second import adds none.
- docs/target-app-parity.md and docs/file-format-parity.md no longer claim
  bibliography sources aren't saved in DOCX: #169 and #532 are closed and
  the round trip exists (#790).

* References: doc comments for the sources import helpers (#2640)

Hoist the untagged-source matching helper (by_author) to module level and
document sources, auto_tag_base and sources_ui, so the functions this PR
touches meet CodeRabbit's docstring-coverage pre-merge check.

* References: re-apply the sources import's progress notes (#2640)

The rebase onto current main took upstream's copies of ROADMAP.md,
docs/file-format-parity.md and docs/gaps.md, because the rows this PR
adds conflicted with upstream's own appends. Re-apply them on the current
text: the progress log and revision rows, the bibliography row's
source-format note, and #15's evidence, with each status line's change
note. The other two documents (docs/roadmap.md, docs/target-app-parity.md)
merged cleanly and keep their rows.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:54:47 -04:00

151 lines
5.9 KiB
Rust

//! 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;
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_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::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<wordcraft_doc::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),
}
}
/// 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<wordcraft_doc::Document, DocxError> {
if is_encrypted(bytes) {
let package = zeroize::Zeroizing::new(decrypt(bytes, password)?);
return read::read_package(&package);
}
read(bytes)
}
/// 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),
}