mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 22:37:10 -06:00
* 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>
151 lines
5.9 KiB
Rust
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),
|
|
}
|