mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 09:11:10 +00:00
* Zotero 1: range fields and custom document properties round-trip Citation managers' ADDIN fields (Zotero, Mendeley, EndNote) are now kept as range fields: FieldStart/FieldEnd markers with the result as ordinary, formatted content between them, possibly spanning paragraphs (a Zotero bibliography). Before, the result was flattened to plain text and a multi-paragraph field lost its end. - doc: InlineObject::FieldStart/FieldEnd, Document::field_ranges, balance_field_ranges; CustomProp and custom_props with get/set/remove. - docx: read ADDIN fields as ranges (field codes up to 2 MiB), write them back as begin/instr/separate…end; orphan markers are dropped on save so files always hold whole fields. docProps/custom.xml is parsed and regenerated (simple types as text, vectors/blobs verbatim) instead of passed through, so Zotero's ZOTERO_PREF_n settings are editable. - engine: file.properties reads and sets custom properties. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 2: wire-protocol client and document bridge New crate wordcraft-zotero (L5) speaks Zotero's published word-processor protocol (port 23116; 32-bit transaction id + big-endian length frames, JSON payloads, ERR: replies): - client: sends an integration command (addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs) and answers Zotero's calls until Document_complete or the connection closes (cancelled dialog). Answers go through a callback so the app can apply them on its UI thread; run_on_session answers directly. - Bridge: implements Zotero's Document_*/Field_* calls on a Session. Fields are stored as Word's plugin stores them (ADDIN ZOTERO_ range fields; prefs in ZOTERO_PREF_n, with Zotero's ReferenceMark field type kept as Word's Field in the file), so documents stay Word-compatible. Rich text (RTF or HTML) keeps citation formatting; bibliographies get a Bibliography style from setBibliographyStyle; note styles create footnotes/endnotes, and deleting a lone note citation removes its note; placeholder links convert to fields. One transaction = one undo step. Field ids stay stable through inserts and deletes. - Tests: a fake Zotero over a real socket, every call, docx round trip, hostile arguments for every method, random frames (proptest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 3: Zotero in the app and the CLI, tested with the real Zotero - ui.zotero.<command> (addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs), ui.zotero.status and ui.zotero.answer. The socket runs on a background thread; each of Zotero's calls is answered on the UI thread against the session, and Zotero's questions become a dialog the session waits on. Web builds report that Zotero needs the desktop app. - wordcraft-cli zotero <command> <file> [--save] [--trace] [--port]. - Bridge: a selection Zotero makes to show a citation is put back when the session ends. - ui_shot: {"wait": ms} keeps frames running for background work. - docs/zotero.md. Checked against Zotero 10.0.6: refresh of a citation and bibliography (APA), its "modified citation" question answered in the app's dialog, RTF results with italics, Bibliography style, prefs round trip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 4: switching between in-text and note styles moves citations Document_convert and Field_convert now move a citation between the text, footnotes and endnotes, as Zotero asks when the document's style changes between an in-text and a note style: into a new note at the same place, or back into the text where the note mark was (removing a note that held only the citation; keeping one with other text). Field ids survive the move, formatting is kept, and each Zotero session stays one undo step. Checked against Zotero 10.0.6: unlink citations (removeCodes, with its confirmation question) and a refresh after switching the document to Chicago notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero 5: Zotero tab in the ribbon A Zotero tab (before Help) with Add/Edit Citation, Add Note, Add/Edit Bibliography, Refresh, Document Preferences and Unlink Citations. The buttons are disabled while a Zotero command is running and in the web build. Three new icons drawn in code (document preferences, unlink, add note); the others reuse the References tab's. Labels and status messages translated (zh-hans, zh-hant, ja). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero: Add/Edit Bibliography inserted nothing Zotero reads a newly inserted bibliography field back before filling it, and unlinks a field whose text is empty, taking it for a bibliography the user deleted. Our new fields were empty, so Add/Edit Bibliography ended with Field_removeCode and nothing in the document. New fields now hold "{Citation}" until Zotero writes the result. Found by tracing the session with Zotero 10.0.6; regression test added. wordcraft-cli zotero takes --cmd (run before the Zotero command, e.g. caret.docEnd) to reproduce sessions from a given caret position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Zotero: log each command's outcome and Zotero's calls to the app log Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cargo.lock: wordcraft-zotero at the workspace version (0.4.0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
82 lines
3.0 KiB
Rust
82 lines
3.0 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 custom;
|
|
mod package;
|
|
mod read;
|
|
mod units;
|
|
mod write;
|
|
mod xml;
|
|
|
|
pub use read::read;
|
|
pub use write::{write, write_as};
|
|
|
|
/// 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),
|
|
}
|