diff --git a/ROADMAP.md b/ROADMAP.md index 86dfeef4f..5132a132b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -142,6 +142,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) | | 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. | | 2026-10-16 | Comment and revision balloons are readable in the dark theme (#2045): they were drawn on a fixed white card under light text; the card, markup area and author colours are now theme tokens with a contrast test. | +| 2026-10-17 | DOCX glossary documents (building blocks) open into the model: Insert › Quick Parts and AutoText list and insert the document's own entries, and saving an entry with `"in": "document"` keeps it in the file (undoable). An untouched glossary is saved back byte for byte; an edited one is written from the model | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | @@ -411,6 +412,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) | +| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-18 | trivial | Progress log: Split Cells and Convert Text to Table dialogs, matched to the Word check (#2592) | | 2026-10-16 | trivial | Progress log: tall-ascent font list entries no longer cut off (#2570) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 797d6c3e4..e0407c5b3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -387,6 +387,40 @@ impl Document { } } +/// A building block (Insert › Quick Parts, AutoText…) kept in the document's glossary +/// (ECMA-376 §17.12: `w:docPart`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct BuildingBlock { + /// The entry's name (`w:docPartPr/w:name`). + pub name: String, + /// Its gallery (`w:category/w:gallery`: `docParts` for Quick Parts, `autoTxt` for AutoText…). + pub gallery: String, + /// Its category within the gallery (`w:category/w:name`, e.g. "General"). + pub category: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub description: String, + /// The entry's identity (`w:guid`), kept as read. + #[serde(skip_serializing_if = "String::is_empty")] + pub guid: String, + /// The paragraph style it's associated with (`w:style`), kept as read. + #[serde(skip_serializing_if = "String::is_empty")] + pub style: String, + /// Its types (`w:types/w:type`: `normal`, `bbPlcHdr` for a content control's placeholder…). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub types: Vec, + /// How it's inserted (`w:behaviors/w:behavior`: `content`, `p`, `pg`). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub behaviors: Vec, + /// The content (`w:docPartBody`). + pub blocks: Blocks, +} + +/// The gallery Quick Parts saves to (`w:gallery w:val="docParts"`). +pub const QUICK_PARTS_GALLERY: &str = "docParts"; +/// The AutoText gallery (`w:gallery w:val="autoTxt"`). +pub const AUTO_TEXT_GALLERY: &str = "autoTxt"; + /// A bibliography source. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] @@ -682,6 +716,9 @@ pub struct Document { /// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub passthrough: BTreeMap>>, + /// The document's own building blocks (its glossary document), in file order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub glossary: Vec, } impl Default for Document { @@ -709,6 +746,7 @@ impl Document { endnote_separators: NoteSeparators::default(), media: BTreeMap::new(), passthrough: BTreeMap::new(), + glossary: Vec::new(), } } diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 1423f0743..441e58e08 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -44,6 +44,38 @@ pub mod rt { pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle"; pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors"; pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml"; + pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument"; +} + +/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8). +pub const GLOSSARY_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"; +/// `Document::passthrough` key: a fingerprint of `Document::glossary` as read, so a save can tell +/// an untouched glossary (written back verbatim) from an edited one (written from the model). +/// Not a package part: WordCraft's own bookkeeping. +pub const GLOSSARY_READ: &str = "wordcraft:glossary.read"; +/// Most building blocks read from one glossary. +pub const MAX_GLOSSARY_ENTRIES: usize = 10_000; +/// The glossary entry types `w:type` takes (ST_DocPartType); others aren't kept. +pub const GLOSSARY_TYPES: [&str; 7] = ["none", "normal", "autoExp", "toolbar", "speller", "formFld", "bbPlcHdr"]; +/// The insertion behaviours `w:behavior` takes (ST_DocPartBehavior); others aren't kept. +pub const GLOSSARY_BEHAVIORS: [&str; 3] = ["content", "p", "pg"]; + +/// A fingerprint of `glossary` (FNV-1a over its debug form): stable across runs and builds that +/// keep the model's shape, and cheap (nothing is allocated). +pub fn glossary_fingerprint(glossary: &[wordcraft_doc::BuildingBlock]) -> String { + struct Fnv(u64); + impl std::fmt::Write for Fnv { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + for b in s.bytes() { + self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3); + } + Ok(()) + } + } + let mut h = Fnv(0xcbf2_9ce4_8422_2325); + // Writing into `Fnv` never fails. + let _ = std::fmt::Write::write_fmt(&mut h, format_args!("{glossary:?}")); + format!("{:016x}", h.0) } /// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run). diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs index d0868436f..0958eef81 100644 --- a/crates/docx/src/read/embed.rs +++ b/crates/docx/src/read/embed.rs @@ -100,11 +100,14 @@ impl Reader<'_> { main.windows(needle.len()).any(|w| w == needle.as_bytes()) }; let mut roots = String::new(); + // One glossary per main document (its first relationship; see `read_glossary`). + let mut glossary = false; for (owner, rels) in [("P", package), ("D", document)] { for rel in rels.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS) { let tail = rel.kind.rsplit('/').next().unwrap_or(""); if HANDLED_RELS.contains(&tail) || (owner == "D" && referenced(&rel.id)) + || (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true)) || ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) { continue; diff --git a/crates/docx/src/read/glossary.rs b/crates/docx/src/read/glossary.rs new file mode 100644 index 000000000..0383f11b7 --- /dev/null +++ b/crates/docx/src/read/glossary.rs @@ -0,0 +1,114 @@ +//! The glossary document (ECMA-376 Part 1 §17.12): the document's own building blocks (Quick +//! Parts, AutoText…), read into `Document::glossary`. +//! +//! The glossary part and everything it reaches are also kept verbatim (`keep_unmodelled`), with a +//! fingerprint of the entries as read ([`GLOSSARY_READ`]): a save writes the kept part while the +//! entries are unchanged, so whatever the model doesn't hold survives, and writes the glossary from +//! the model once they change. + +use std::sync::Arc; + +use wordcraft_doc::{Blocks, BuildingBlock}; + +use super::Reader; +use super::story::StoryCtx; +use crate::package::{ + ContentTypes, GLOSSARY_BEHAVIORS, GLOSSARY_CONTENT_TYPE, GLOSSARY_READ, GLOSSARY_TYPES, MAX_GLOSSARY_ENTRIES, Rels, glossary_fingerprint, rel_is, + rt, +}; +use crate::xml::El; + +/// Longest name, gallery, category, description, guid or behaviour value kept (characters). +const MAX_FIELD: usize = 1024; + +/// The known values of `parent`'s `tag` children, each once, in file order. +fn listed(parent: Option<&El>, tag: &str, known: &[&str]) -> Vec { + let mut out: Vec = Vec::new(); + for v in parent.into_iter().flat_map(|p| p.children(tag)).filter_map(|e| e.attr("w:val")) { + if known.contains(&v) && !out.iter().any(|o| o == v) { + out.push(v.to_string()); + } + } + out +} + +impl Reader<'_> { + /// Read the glossary the main document relates to (`document`: its relationships). The first + /// internal glossary relationship counts; one pointing at the main document, a part that isn't a + /// glossary, or a missing one gives no entries. Entries without a name or a body are skipped. + pub(super) fn read_glossary(&mut self, document: &Rels) { + // The first internal one, as `keep_unmodelled` keeps. + let Some(rel) = document.list.iter().find(|r| !r.external && rel_is(&r.kind, rt::GLOSSARY)) else { return }; + let mut entries = Vec::new(); + if !rel.target.eq_ignore_ascii_case(&self.main) { + let pkg = self.pkg; + let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&rel.target).map(str::to_string); + let root = match self.xml(&rel.target) { + Ok(r) => r, + Err(e) => { + log::warn!("docx: ignoring unreadable glossary {}: {e}", rel.target); + None + } + }; + // Without content types (a converted package) the root element alone decides. + if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) { + let rels = self.pkg.rels(&rel.target); + entries = self.doc_parts(&root, &rels); + } + } + self.doc.glossary = entries; + let print = glossary_fingerprint(&self.doc.glossary); + self.doc.passthrough.insert(GLOSSARY_READ.into(), Arc::new(print.into_bytes())); + } + + /// The building blocks of a `w:glossaryDocument`, read with the glossary's relationships. + fn doc_parts(&mut self, root: &El, rels: &Rels) -> Vec { + // The glossary's notes, comments, bookmarks, permissions and moves are its own: what the + // main document's ids mean doesn't apply to it, and its ids mustn't leak into the body's. + let footnotes = std::mem::take(&mut self.footnotes); + let endnotes = std::mem::take(&mut self.endnotes); + let comments = std::mem::take(&mut self.comment_map); + let ended = std::mem::take(&mut self.comments_ended); + let bookmarks = std::mem::take(&mut self.bookmarks); + let perms = std::mem::take(&mut self.perms); + let moves = std::mem::take(&mut self.moves); + let mut out = Vec::new(); + for part in root.child("w:docParts").into_iter().flat_map(|d| d.children("w:docPart")) { + if out.len() >= MAX_GLOSSARY_ENTRIES { + log::warn!("docx: glossary has more than {MAX_GLOSSARY_ENTRIES} entries; the rest are dropped"); + break; + } + let pr = part.child("w:docPartPr"); + let val = |e: Option<&El>| e.and_then(|e| e.attr("w:val")).unwrap_or("").chars().take(MAX_FIELD).collect::(); + let name = val(pr.and_then(|p| p.child("w:name"))); + let Some(body) = part.child("w:docPartBody").filter(|_| !name.is_empty()) else { continue }; + let category = pr.and_then(|p| p.child("w:category")); + let mut sc = StoryCtx::default(); + let mut blocks = Blocks::new(); + self.read_blocks(&mut sc, body, rels, &mut blocks, 0); + self.flush_pending(&mut sc, &mut blocks); + if blocks.is_empty() { + continue; + } + out.push(BuildingBlock { + name, + gallery: val(category.and_then(|c| c.child("w:gallery"))), + category: val(category.and_then(|c| c.child("w:name"))), + description: val(pr.and_then(|p| p.child("w:description"))), + guid: val(pr.and_then(|p| p.child("w:guid"))), + style: val(pr.and_then(|p| p.child("w:style"))), + types: listed(pr.and_then(|p| p.child("w:types")), "w:type", &GLOSSARY_TYPES), + behaviors: listed(pr.and_then(|p| p.child("w:behaviors")), "w:behavior", &GLOSSARY_BEHAVIORS), + blocks, + }); + } + self.footnotes = footnotes; + self.endnotes = endnotes; + self.comment_map = comments; + self.comments_ended = ended; + self.bookmarks = bookmarks; + self.perms = perms; + self.moves = moves; + out + } +} diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 523b50c08..252804342 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod diagram; mod drawing_color; mod embed; mod freeform; +mod glossary; pub(crate) mod math; mod props; mod sdt; @@ -286,6 +287,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec())); r.read_vba_related(&v); } + r.read_glossary(&rels); r.keep_custom_xml(&root_rels, &rels); r.keep_unmodelled(&root_rels, &rels); if !r.embedded.parts.is_empty() { diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 77ddebdd0..48cca9bdd 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -46,6 +46,8 @@ pub(crate) struct EmbedWriter<'d> { markup_bytes: u64, /// Parts to write: (name, bytes, content type, relationships). pub parts: Vec<(String, Vec, String, PartRels)>, + /// Leave out the main document's kept glossary (it's written from the model instead). + pub drop_glossary: bool, } impl<'d> EmbedWriter<'d> { @@ -90,6 +92,7 @@ impl<'d> EmbedWriter<'d> { copy_bytes: MAX_COPY_BYTES, markup_bytes: MAX_MARKUP_BYTES, parts: Vec::new(), + drop_glossary: false, } } @@ -142,7 +145,7 @@ impl<'d> EmbedWriter<'d> { } /// A number `n` for which every `{stem}{n}{ext}` is a free part name, with those names taken. - fn fresh_set(&mut self, stems: &[&str], ext: &str) -> Vec { + pub(super) fn fresh_set(&mut self, stems: &[&str], ext: &str) -> Vec { let mut n: u32 = 1; loop { let names: Vec = stems.iter().map(|s| format!("{s}{n}{ext}")).collect(); @@ -205,7 +208,7 @@ impl<'d> EmbedWriter<'d> { for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) { let fields: Vec<&str> = line.split('\t').collect(); let [source, kind, target] = fields.as_slice() else { continue }; - if *source != owner { + if *source != owner || (self.drop_glossary && owner == "D" && rel_is(kind, rt::GLOSSARY)) { continue; } if let Some(name) = self.copy_tree(target, &mut memo, &mut written) { @@ -216,6 +219,15 @@ impl<'d> EmbedWriter<'d> { self.first = memo; } + /// Whether `unmodelled_rels("D", …)` can write the main document's kept glossary back. + pub fn kept_glossary(&self) -> bool { + let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS) else { return false }; + String::from_utf8_lossy(roots).lines().take(MAX_EMBEDDED_RELS * 2).any(|line| { + let fields: Vec<&str> = line.split('\t').collect(); + matches!(fields.as_slice(), [source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) && (self.first.contains_key(*target) || self.available(target))) + }) + } + /// What copying the parts reached from `roots` costs against the copy budget. fn tree_cost(&self, roots: &[&str]) -> u64 { let mut seen: HashSet<&str> = HashSet::new(); diff --git a/crates/docx/src/write/glossary.rs b/crates/docx/src/write/glossary.rs new file mode 100644 index 000000000..21e73514a --- /dev/null +++ b/crates/docx/src/write/glossary.rs @@ -0,0 +1,95 @@ +//! Writing the glossary document (ECMA-376 Part 1 §17.12) from `Document::glossary`, once its +//! entries differ from the file's (an untouched glossary is written back verbatim: see +//! `read::glossary`). + +use wordcraft_doc::{Document, QUICK_PARTS_GALLERY}; + +use super::{CT_WML, PartRels, Writer, numbering_xml, push_part, styles_xml}; +use crate::package::{GLOSSARY_BEHAVIORS, GLOSSARY_CONTENT_TYPE, GLOSSARY_READ, GLOSSARY_TYPES, glossary_fingerprint, rt}; +use crate::xml::{self, W}; + +/// `values` known to `known`, as `parent` with one `tag` child each (nothing when none are). +fn listed(w: &mut W, parent: &str, tag: &str, values: &[String], known: &[&str]) { + let mut values = values.iter().filter(|v| known.contains(&v.as_str())).peekable(); + if values.peek().is_none() { + return; + } + w.open(parent, &[]); + for v in values { + w.empty(tag, &[("w:val", v)]); + } + w.close(parent); +} + +/// Whether the glossary differs from the one read from the file (none read: an empty one). +pub(super) fn edited(doc: &Document) -> bool { + let print = glossary_fingerprint(&doc.glossary); + match doc.passthrough.get(GLOSSARY_READ) { + Some(read) => read.as_slice() != print.as_bytes(), + None => print != glossary_fingerprint(&[]), + } +} + +impl Writer<'_> { + /// Write the glossary part from the model, with a styles part of its own (the document's + /// styles), related from the main document's `rels`. Its parts live in `word/`, beside the + /// main document, so the relationships its stories add resolve as theirs do. + pub(super) fn glossary(&mut self, entries: &mut Vec<(String, Vec)>, overrides: &mut Vec<(String, String)>, rels: &mut PartRels) { + let doc = self.doc; + if doc.glossary.is_empty() { + return; + } + let names = self.embeds.fresh_set(&["word/glossary", "word/glossaryStyles", "word/glossaryNumbering"], ".xml"); + let [part, styles, numbering] = names.as_slice() else { return }; + let ns = xml::body_ns(); + let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let mut prels = PartRels::default(); + let mut w = W::new(); + w.open("w:glossaryDocument", &ns_refs); + w.open("w:docParts", &[]); + for e in &doc.glossary { + w.open("w:docPart", &[]); + w.open("w:docPartPr", &[]); + w.empty("w:name", &[("w:val", &e.name)]); + if !e.style.is_empty() { + w.empty("w:style", &[("w:val", &e.style)]); + } + w.open("w:category", &[]); + w.empty("w:name", &[("w:val", if e.category.is_empty() { "General" } else { &e.category })]); + w.empty("w:gallery", &[("w:val", if e.gallery.is_empty() { QUICK_PARTS_GALLERY } else { &e.gallery })]); + w.close("w:category"); + listed(&mut w, "w:types", "w:type", &e.types, &GLOSSARY_TYPES); + listed(&mut w, "w:behaviors", "w:behavior", &e.behaviors, &GLOSSARY_BEHAVIORS); + if !e.description.is_empty() { + w.empty("w:description", &[("w:val", &e.description)]); + } + if !e.guid.is_empty() { + w.empty("w:guid", &[("w:val", &e.guid)]); + } + w.close("w:docPartPr"); + w.open("w:docPartBody", &[]); + if e.blocks.is_empty() { + w.empty("w:p", &[]); + } else { + self.blocks(&mut w, &e.blocks, &mut prels, false, 0); + } + w.close("w:docPartBody"); + w.close("w:docPart"); + } + w.close("w:docParts"); + w.close("w:glossaryDocument"); + let file = |p: &str| p.strip_prefix("word/").unwrap_or(p).to_string(); + prels.add(rt::STYLES, &file(styles), false); + // Lists in entries need their definitions within the glossary's reach. + let lists = !doc.numbering.nums.is_empty() || !doc.numbering.abstracts.is_empty(); + if lists { + prels.add(rt::NUMBERING, &file(numbering), false); + } + rels.add(rt::GLOSSARY, &file(part), false); + push_part(entries, overrides, part, w.into_bytes(), GLOSSARY_CONTENT_TYPE, prels); + push_part(entries, overrides, styles, styles_xml(doc), &format!("{CT_WML}styles+xml"), PartRels::default()); + if lists { + push_part(entries, overrides, numbering, numbering_xml(doc), &format!("{CT_WML}numbering+xml"), PartRels::default()); + } + } +} diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index f5b09b96f..7ac86e9d5 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1,6 +1,7 @@ //! [`Document`] → DOCX. mod embed; +mod glossary; mod settings_merge; pub(crate) use settings_merge::has_unowned as settings_has_unowned; mod math; @@ -263,6 +264,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result)]) -> Vec { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in entries { + zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn unzip(bytes: &[u8]) -> BTreeMap> { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = BTreeMap::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut b = Vec::new(); + f.read_to_end(&mut b).unwrap(); + assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name()); + } + out +} + +fn text(files: &BTreeMap>, name: &str) -> String { + String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap() +} + +fn rels(list: &[(&str, &str, &str)]) -> String { + let mut s = + String::from(r#""#); + for (id, ty, target) in list { + s.push_str(&format!(r#""#)); + } + s.push_str(""); + s +} + +fn content_types(extra: &[(&str, &str)]) -> String { + let mut s = String::from( + r#""#, + ); + for (part, ct) in extra { + s.push_str(&format!(r#""#)); + } + s.push_str(""); + s +} + +/// A package with body text, a main document relating to `doc_rels`, and `extra` parts. +fn package(doc_rels: &[(&str, &str, &str)], extra: &[(&str, String)]) -> Vec { + let doc = format!( + r#"Body"# + ); + let root = rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")]); + let mut entries: Vec<(&str, Vec)> = vec![ + ("_rels/.rels", root.into_bytes()), + ("word/document.xml", doc.into_bytes()), + ("word/_rels/document.xml.rels", rels(doc_rels).into_bytes()), + ]; + for (p, c) in extra { + entries.push((p, c.as_bytes().to_vec())); + } + zip(&entries) +} + +fn glossary_xml(doc_parts: &str) -> String { + format!( + r#"{doc_parts}"# + ) +} + +const SIGNATURE: &str = r#"Kind regards, Ann"#; +const PRICES: &str = r#"ItemPricePrices exclude tax."#; +const GLOSSARY_STYLES: &str = r#""#; +const GLOSSARY_WEB: &str = + r#""#; + +/// A document whose glossary holds a Quick Part and an AutoText entry (with a table), with the +/// glossary's own styles and web settings. +fn with_glossary() -> Vec { + package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ( + "[Content_Types].xml", + content_types(&[ + ("/word/glossary/document.xml", GLOSSARY_CT), + ("/word/glossary/styles.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"), + ("/word/glossary/webSettings.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"), + ]), + ), + ("word/glossary/document.xml", glossary_xml(&format!("{SIGNATURE}{PRICES}"))), + ( + "word/glossary/_rels/document.xml.rels", + rels(&[("rId1", &format!("{REL}/styles"), "styles.xml"), ("rId2", &format!("{REL}/webSettings"), "webSettings.xml")]), + ), + ("word/glossary/styles.xml", GLOSSARY_STYLES.to_string()), + ("word/glossary/webSettings.xml", GLOSSARY_WEB.to_string()), + ], + ) +} + +fn save(doc: &Document) -> BTreeMap> { + unzip(&wordcraft_docx::write(doc).expect("write")) +} + +fn block_text(b: &Block) -> String { + match b { + Block::Para(p) => p.text.clone(), + Block::Table(t) => { + t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| block_text(b)).collect::>().join("|") + } + } +} + +fn entry_text(e: &BuildingBlock) -> String { + e.blocks.iter().map(|b| block_text(b)).collect::>().join("\n") +} + +/// The glossary part a saved package's main document relates to. +fn glossary_target(files: &BTreeMap>) -> Option { + let doc_rels = text(files, "word/_rels/document.xml.rels"); + assert!(doc_rels.matches("/glossaryDocument\"").count() <= 1, "{doc_rels}"); + let at = doc_rels.find("/glossaryDocument\"")?; + let rest = doc_rels.get(at..)?; + let t = rest.find("Target=\"")? + "Target=\"".len(); + let end = rest.get(t..)?.find('"')?; + Some(format!("word/{}", rest.get(t..t + end)?)) +} + +#[test] +fn unchanged_glossary_is_carried_byte_for_byte() { + let bytes = with_glossary(); + let mut doc = wordcraft_docx::read(&bytes).unwrap(); + doc.body.push(para_block(Paragraph::with_text("Added after opening", CharProps::default()))); + let files = save(&doc); + let input = unzip(&bytes); + assert_eq!(glossary_target(&files).as_deref(), Some("word/glossary/document.xml")); + for part in ["word/glossary/document.xml", "word/glossary/styles.xml", "word/glossary/webSettings.xml"] { + assert_eq!(files.get(part), input.get(part), "{part} not carried verbatim"); + } + let grels = text(&files, "word/glossary/_rels/document.xml.rels"); + assert!(grels.contains("styles.xml") && grels.contains("webSettings.xml"), "{grels}"); + let ct = text(&files, "[Content_Types].xml"); + assert!(ct.contains(&format!(r#"PartName="/word/glossary/document.xml" ContentType="{GLOSSARY_CT}""#)), "{ct}"); + assert!(ct.contains(r#"PartName="/word/glossary/styles.xml""#), "{ct}"); + let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + assert_eq!(again.glossary.len(), 2); + assert!(again.plain_text(wordcraft_doc::StoryRef::Body).contains("Added after opening")); +} + +#[test] +fn glossary_entries_are_read_into_the_model() { + let doc = wordcraft_docx::read(&with_glossary()).unwrap(); + let g = &doc.glossary; + assert_eq!(g.iter().map(|e| e.name.as_str()).collect::>(), ["Signature", "Price table"]); + let sig = &g[0]; + assert_eq!((sig.gallery.as_str(), sig.category.as_str()), ("docParts", "General")); + assert_eq!(sig.description, "A closing line"); + assert_eq!(sig.guid, "{0F1E2D3C-4B5A-6978-8796-A5B4C3D2E1F0}"); + assert_eq!( + (sig.style.as_str(), sig.types.as_slice(), sig.behaviors.as_slice()), + ("Normal", &["normal".to_string()][..], &["content".to_string()][..]) + ); + assert_eq!(entry_text(sig), "Kind regards, Ann"); + let prices = &g[1]; + assert_eq!((prices.gallery.as_str(), prices.category.as_str()), ("autoTxt", "Sales")); + // Types and behaviours outside the spec's lists aren't kept. + assert_eq!(prices.types, ["bbPlcHdr", "autoExp"]); + assert_eq!(prices.behaviors, ["p", "pg"]); + assert!(matches!(&*prices.blocks[0], Block::Table(_)), "{:?}", prices.blocks); + assert_eq!(entry_text(prices), "Item|Price\nPrices exclude tax."); + // The glossary's text isn't the document's. + assert!(!doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Kind regards")); +} + +#[test] +fn edited_glossary_is_written_from_the_model() { + let mut doc = wordcraft_docx::read(&with_glossary()).unwrap(); + doc.glossary[0].name = "Sign-off".into(); + let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet); + let mut listed = Paragraph::with_text("Second line", CharProps::default()); + listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); + doc.glossary.push(BuildingBlock { + name: "Address".into(), + gallery: "docParts".into(), + category: "General".into(), + blocks: vec![para_block(Paragraph::with_text("1 Main Street", CharProps::default())), para_block(listed)], + ..Default::default() + }); + let files = save(&doc); + let target = glossary_target(&files).expect("a glossary relationship"); + let g = text(&files, &target); + assert!(g.contains("w:glossaryDocument") && g.contains("Sign-off") && g.contains("1 Main Street"), "{g}"); + assert!(!files.values().any(|b| String::from_utf8_lossy(b).contains(r#"w:val="Signature""#)), "the old glossary is gone"); + let ct = text(&files, "[Content_Types].xml"); + assert!(ct.contains(&format!(r#"PartName="/{target}" ContentType="{GLOSSARY_CT}""#)), "{ct}"); + let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + assert_eq!(again.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Sign-off", "Price table", "Address"]); + assert_eq!(entry_text(&again.glossary[1]), "Item|Price\nPrices exclude tax."); + assert_eq!(again.glossary[1].gallery, "autoTxt"); + assert_eq!(again.glossary[0].guid, "{0F1E2D3C-4B5A-6978-8796-A5B4C3D2E1F0}"); + assert_eq!(again.glossary[0].description, "A closing line"); + assert_eq!(entry_text(&again.glossary[2]), "1 Main Street\nSecond line"); + // Style, types and behaviours survive the rewrite. + for (a, b) in again.glossary.iter().zip(&doc.glossary) { + assert_eq!((&a.style, &a.types, &a.behaviors), (&b.style, &b.types, &b.behaviors), "{}", a.name); + } + assert_eq!(again.glossary[1].types, ["bbPlcHdr", "autoExp"]); + assert_eq!(again.glossary[1].behaviors, ["p", "pg"]); + assert_eq!(again.glossary[0].style, "Normal"); + // A list in an entry has its definitions beside the glossary (its own numbering part). + let grels = text(&files, &glossary_rels(&target)); + assert!(grels.contains("/numbering\""), "{grels}"); + let numbering = grels.split("/numbering\" Target=\"").nth(1).and_then(|r| r.split('"').next()).expect("numbering target"); + let numbering = text(&files, &format!("word/{numbering}")); + assert!(numbering.contains("w:abstractNum") && numbering.contains(&format!(r#"w:numId="{num}""#)), "{numbering}"); + // Saving the reopened document again keeps its (now unchanged) glossary verbatim. + let twice = save(&again); + assert_eq!(glossary_target(&twice).as_deref(), Some(target.as_str())); + assert_eq!(twice.get(&target), files.get(&target)); +} + +/// The relationships part of a part in `word/`. +fn glossary_rels(part: &str) -> String { + let (dir, file) = part.rsplit_once('/').unwrap(); + format!("{dir}/_rels/{file}.rels") +} + +#[test] +fn an_unchanged_glossary_whose_bytes_werent_kept_is_written_from_the_model() { + let mut doc = wordcraft_docx::read(&with_glossary()).unwrap(); + // The verbatim copy is gone (over the kept-part caps, say): the model is all there is. + doc.passthrough.remove("word/glossary/document.xml"); + let files = save(&doc); + let target = glossary_target(&files).expect("a glossary relationship"); + assert!(text(&files, &target).contains("Kind regards, Ann")); + let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + assert_eq!(again.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature", "Price table"]); +} + +#[test] +fn an_external_glossary_relationship_is_passed_over() { + let doc_rels = format!( + r#""# + ); + let doc = format!( + r#"Body"# + ); + let bytes = zip(&[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)]).into_bytes()), + ("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")]).into_bytes()), + ("word/document.xml", doc.into_bytes()), + ("word/_rels/document.xml.rels", doc_rels.into_bytes()), + ("word/glossary/document.xml", glossary_xml(SIGNATURE).into_bytes()), + ]); + let doc = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(doc.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature"]); + // The same glossary is the one kept: saved unchanged, it's written back as it was. + let files = save(&doc); + assert_eq!(files.get("word/glossary/document.xml"), unzip(&bytes).get("word/glossary/document.xml")); +} + +#[test] +fn a_document_without_glossary_writes_none() { + let files = save(&Document::new()); + assert_eq!(glossary_target(&files), None); + assert!(!text(&files, "[Content_Types].xml").contains("glossary")); + // Removing every entry removes the glossary. + let mut doc = wordcraft_docx::read(&with_glossary()).unwrap(); + doc.glossary.clear(); + let files = save(&doc); + assert_eq!(glossary_target(&files), None); + assert!(!files.keys().any(|k| k.contains("glossary")), "{:?}", files.keys()); + assert!(!text(&files, "[Content_Types].xml").contains("glossary")); + assert!(wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap().glossary.is_empty()); +} + +#[test] +fn a_new_entry_in_a_new_document_makes_a_glossary() { + let mut doc = Document::new(); + doc.glossary.push(BuildingBlock { + name: "Greeting".into(), + gallery: "docParts".into(), + category: "General".into(), + blocks: vec![para_block(Paragraph::with_text("Hello there", CharProps::default()))], + ..Default::default() + }); + let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + assert_eq!(again.glossary.len(), 1); + assert_eq!(again.glossary[0].name, "Greeting"); + assert_eq!(entry_text(&again.glossary[0]), "Hello there"); + assert!(again.plain_text(wordcraft_doc::StoryRef::Body).trim().is_empty()); +} + +/// Hostile glossaries open without a panic; entries that can't be read are skipped. +#[test] +fn hostile_glossaries_never_panic() { + let ct = content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)]); + let one = |g: String| { + package(&[("rId7", GLOSSARY_REL, "glossary/document.xml")], &[("[Content_Types].xml", ct.clone()), ("word/glossary/document.xml", g)]) + }; + let read = |bytes: Vec| -> Document { + let doc = wordcraft_docx::read(&bytes).unwrap(); + // Saving and reopening must not panic either. + let _ = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + doc + }; + // No body, an empty body, no name, no properties: skipped. + let doc = read(one(glossary_xml( + r#"nameless"#, + ))); + assert!(doc.glossary.iter().all(|e| !e.name.is_empty() && !e.blocks.is_empty()), "{:?}", doc.glossary); + assert!(doc.glossary.iter().all(|e| e.name != "NoBody")); + // Duplicate names: both read, file order. + let doc = read(one(glossary_xml(&format!("{SIGNATURE}{SIGNATURE}")))); + assert_eq!(doc.glossary.len(), 2); + // Huge counts are capped. + let many: String = (0..12_000) + .map(|i| format!(r#""#)) + .collect(); + let doc = read(one(glossary_xml(&many))); + assert!(doc.glossary.len() <= 10_000, "{}", doc.glossary.len()); + // Deep nesting (tables in tables). + let deep = format!( + r#"{}{}"#, + "".repeat(400), + "".repeat(400) + ); + let _ = read(one(glossary_xml(&deep))); + // Not a glossary at all, or a wrong content type: nothing read. + let doc = read(one(format!(r#"x"#))); + assert!(doc.glossary.is_empty()); + let wrong = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", "image/png")])), + ("word/glossary/document.xml", glossary_xml(SIGNATURE)), + ], + ); + assert!(read(wrong).glossary.is_empty()); + // Broken XML. + let _ = read(one("".into())); + // A glossary relationship pointing at the main document, or a missing part. + for target in ["document.xml", "missing.xml", "../../../../etc/passwd"] { + let doc = read(package(&[("rId7", GLOSSARY_REL, target)], &[("[Content_Types].xml", ct.clone())])); + assert!(doc.glossary.is_empty(), "{target}"); + assert!(!doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Kind regards")); + } + // A glossary relating to itself, or with a glossary of its own. + for own in + [rels(&[("rId1", GLOSSARY_REL, "document.xml")]), rels(&[("rId1", GLOSSARY_REL, "inner.xml"), ("rId2", GLOSSARY_REL, "../document.xml")])] + { + let bytes = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT), ("/word/glossary/inner.xml", GLOSSARY_CT)])), + ("word/glossary/document.xml", glossary_xml(SIGNATURE)), + ("word/glossary/_rels/document.xml.rels", own), + ("word/glossary/inner.xml", glossary_xml(PRICES)), + ], + ); + let doc = read(bytes); + assert_eq!(doc.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature"]); + } + // Two glossary relationships: the first counts. + let two = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml"), ("rId8", GLOSSARY_REL, "glossary/document.xml")], + &[("[Content_Types].xml", ct.clone()), ("word/glossary/document.xml", glossary_xml(SIGNATURE))], + ); + let doc = read(two); + assert_eq!(doc.glossary.len(), 1); +} diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 1243ba4f2..3a2fa008a 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -455,11 +455,12 @@ pub fn specs() -> Vec { Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."})) }) .pure(), - CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts) - .params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#), - CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts) + CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", |s, v| quick_parts(s, v, wordcraft_doc::QUICK_PARTS_GALLERY)).params( + r#"{"save"?: name (from the selection), "in"?: "document" (save into the document's own building blocks, kept in its file; else this session's), "insert"?: name, "delete"?: name} → {"parts": names (this session's, then the document's Quick Parts), "document": the document's Quick Parts}"#, + ), + CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", |s, v| quick_parts(s, v, wordcraft_doc::AUTO_TEXT_GALLERY)) .key(super::non_mac("Alt+F3")) - .params(r#"{"save"?: name, "insert"?: name}"#), + .params(r#"{"save"?: name, "in"?: "document", "insert"?: name, "delete"?: name} → {"parts": names, "document": the document's AutoText}"#), CommandSpec::new("insert.docProperty", "Document Property", "Insert › Text › Quick Parts", |s, v| { let name = p::str(v, "name").unwrap_or("Title").to_string(); let c = &s.doc.core; @@ -1552,25 +1553,66 @@ fn tables_hiding_text(doc: &Document, story: StoryRef, blocks: &wordcraft_doc::B } } -fn quick_parts(s: &mut Session, v: &Value) -> CmdResult { +/// Insert › Quick Parts and AutoText: this session's building blocks, and the document's own +/// (`Document::glossary`, kept in its file) in `gallery`. A name is looked up in the session's +/// first, then in the document's (`gallery` first). +fn quick_parts(s: &mut Session, v: &Value, gallery: &str) -> CmdResult { + let in_document = p::str(v, "in") == Some("document"); if let Some(name) = p::str(v, "save") { let (a, b) = s.sel.ordered(); if a == b { return Err(CmdError::Params("select the content to save".into())); } let frag = s.doc.copy_range(&a, &b); - s.building_blocks.insert(name.to_string(), frag); + if in_document { + if name.trim().is_empty() { + return Err(CmdError::Params("a building block needs a name".into())); + } + let blocks: wordcraft_doc::Blocks = frag.blocks.into_iter().map(Arc::new).collect(); + match s.doc.glossary.iter_mut().find(|e| e.name == name && e.gallery == gallery) { + Some(e) => e.blocks = blocks, + None => s.doc.glossary.push(wordcraft_doc::BuildingBlock { + name: name.to_string(), + gallery: gallery.to_string(), + category: "General".into(), + blocks, + ..Default::default() + }), + } + } else { + s.building_blocks.insert(name.to_string(), frag); + } } - if let Some(name) = p::str(v, "delete") { - s.building_blocks.remove(name); + if let Some(name) = p::str(v, "delete") + && (in_document || s.building_blocks.remove(name).is_none()) + && let Some(i) = document_entry(&s.doc, name, gallery) + { + s.doc.glossary.remove(i); } if let Some(name) = p::str(v, "insert") { - let frag = s.building_blocks.get(name).cloned().ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?; + let frag = match s.building_blocks.get(name) { + Some(f) => f.clone(), + None => { + let e = document_entry(&s.doc, name, gallery).and_then(|i| s.doc.glossary.get(i)); + let e = e.ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?; + wordcraft_doc::edit::Fragment { blocks: e.blocks.iter().map(|b| (**b).clone()).collect(), parts: Default::default() } + } + }; let at = delete_selection(s)?; let end = s.doc.insert_fragment(&at, &frag)?; s.sel = Selection::caret(end); } - Ok(json!({"parts": s.building_blocks.keys().collect::>()})) + let document: Vec<&str> = s.doc.glossary.iter().filter(|e| e.gallery == gallery).map(|e| e.name.as_str()).collect(); + let mut parts: Vec<&str> = s.building_blocks.keys().map(String::as_str).collect(); + let mut seen: std::collections::HashSet<&str> = parts.iter().copied().collect(); + parts.extend(document.iter().copied().filter(|n| seen.insert(n))); + Ok(json!({"parts": parts, "document": document})) +} + +/// The index of the document's building block `name`, in `gallery` if there is one there. +fn document_entry(doc: &Document, name: &str, gallery: &str) -> Option { + let g = &doc.glossary; + g.iter().position(|e| e.name == name && e.gallery == gallery).or_else(|| g.iter().position(|e| e.name == name)) } fn hf_nav(s: &mut Session, dir: i32) -> CmdResult { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 44ae06b0a..3a84014d1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7562,3 +7562,55 @@ fn draft_keeps_its_column_after_web_layout() { let restored: crate::ViewState = serde_json::from_value(serialized).unwrap(); assert_eq!(restored.web_width, 800.0); } + +/// Insert › Quick Parts and AutoText list, insert and save the document's own building blocks +/// (its glossary, kept in the file); saving with `"in": "document"` is undoable. +#[test] +fn quick_parts_use_the_documents_building_blocks() { + use wordcraft_doc::{BuildingBlock, CharProps, Paragraph, para_block}; + let entry = |name: &str, gallery: &str, text: &str| BuildingBlock { + name: name.into(), + gallery: gallery.into(), + category: "General".into(), + blocks: vec![para_block(Paragraph::with_text(text, CharProps::default()))], + ..Default::default() + }; + let mut d = wordcraft_doc::Document::new(); + d.glossary = vec![entry("Signature", "docParts", "Kind regards, Ann"), entry("Disclaimer", "autoTxt", "No warranty.")]; + let bytes = wordcraft_docx::write(&d).unwrap(); + let mut s = Session::new(crate::io::open_bytes("glossary.docx", &bytes).unwrap()); + let listed = run(&mut s, "insert.quickParts", json!({})); + assert_eq!(listed["document"], json!(["Signature"]), "{listed}"); + assert_eq!(listed["parts"], json!(["Signature"]), "{listed}"); + assert_eq!(run(&mut s, "insert.autoText", json!({}))["document"], json!(["Disclaimer"])); + run(&mut s, "insert.quickParts", json!({"insert": "Signature"})); + assert!(text(&s).contains("Kind regards, Ann"), "{}", text(&s)); + run(&mut s, "insert.autoText", json!({"insert": "Disclaimer"})); + assert!(text(&s).contains("No warranty."), "{}", text(&s)); + + // Saved into the document: undoable, and kept in its file. + run(&mut s, "select.text", json!({"text": "Kind regards"})); + s.dirty = false; + let listed = run(&mut s, "insert.quickParts", json!({"save": "Closing", "in": "document"})); + assert_eq!(listed["document"], json!(["Signature", "Closing"]), "{listed}"); + assert!(s.building_blocks.is_empty(), "not in the session's store"); + assert!(s.dirty); + run(&mut s, "edit.undo", json!({})); + assert!(!s.doc.glossary.iter().any(|e| e.name == "Closing")); + run(&mut s, "edit.redo", json!({})); + let back = crate::io::open_bytes("glossary.docx", &wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let closing = back.glossary.iter().find(|e| e.name == "Closing").expect("saved entry"); + assert_eq!(closing.gallery, "docParts"); + assert_eq!(closing.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect::(), "Kind regards"); + assert_eq!(back.glossary.len(), 3); + + // Without "in", saving stays in the session; deleting removes the name where it lives. + run(&mut s, "insert.quickParts", json!({"save": "Scratch"})); + assert!(s.building_blocks.contains_key("Scratch") && !s.doc.glossary.iter().any(|e| e.name == "Scratch")); + run(&mut s, "insert.quickParts", json!({"delete": "Scratch"})); + run(&mut s, "insert.quickParts", json!({"delete": "Closing"})); + assert!(s.building_blocks.is_empty()); + assert_eq!(s.doc.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature", "Disclaimer"]); + assert!(s.run("insert.quickParts", &json!({"save": " ", "in": "document"})).is_err()); + assert!(s.run("insert.quickParts", &json!({"insert": "Closing"})).is_err()); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0dd38891e..ddb272ea7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1102,7 +1102,11 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "textBox", "Text\nBox", "insert.textBox", json!({}), false); stack(ui, |ui| { menu_button(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", false, "insert.quickParts", |ui, app| { - let parts: Vec = app.session.building_blocks.keys().cloned().collect(); + // This session's building blocks, then the document's own Quick Parts. + let mut parts: Vec = app.session.building_blocks.keys().cloned().collect(); + let mut seen: std::collections::HashSet = parts.iter().cloned().collect(); + let glossary = app.session.doc.glossary.iter().filter(|e| e.gallery == wordcraft_doc::QUICK_PARTS_GALLERY); + parts.extend(glossary.filter(|e| seen.insert(e.name.clone())).map(|e| e.name.clone())); for name in parts { if ui.button(&name).clicked() { let _ = app.run("insert.quickParts", json!({"insert": name})); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 883de70bf..903471da8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations); previously trivial (every table style part round-trips, #714); previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** trivial (DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations); previously trivial (every table style part round-trips, #714); previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -98,7 +98,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | | Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read | | `w:eastAsianLayout`, `w:fitText` | ❌ | ❌ | East Asian layout | -| Glossary document (building blocks), `w:altChunk`, sub-documents | ❌ | ❌ | | +| Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/glossary.rs` | +| `w:altChunk`, sub-documents | ❌ | ❌ | | | Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard | | Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written | @@ -194,8 +195,9 @@ in `w:compat` only `compatibilityMode` is replaced. Kept children that point at ## Unmodelled-part preservation Parts the package or the main document relate to that WordCraft neither models nor writes (web -settings, styles-with-effects, add-in task panes, people, the thumbnail, a glossary, future parts) -are carried verbatim with their relationships and content types. Relationship types the writer +settings, styles-with-effects, add-in task panes, people, the thumbnail, future parts) are +carried verbatim with their relationships and content types. The glossary document is carried the +same way while its building blocks are as read; once they change it is written from the model. Relationship types the writer produces, and any the document's markup refers to by id, are not (`HANDLED_RELS` in `crates/docx/src/read/embed.rs`), so nothing is written twice. `app.xml` keeps Template, Manager, Company, HyperlinkBase and the link/sharing flags; counts (pages, lines, characters, editing @@ -216,6 +218,7 @@ Imported text fields use the first nonempty cached result run's formatting on th |---|---|---| | 2026-10-16 | trivial | Cached textual `REF` results with `\h` export links to their bookmarks. Code view and existing enclosing hyperlinks remain unchanged; unresolved bookmarks emit no PDF link. | | 2026-10-16 | trivial | Cached text fields retain result-run formatting instead of field-begin formatting. | +| 2026-10-17 | trivial | Glossary document (building blocks) read into the model, kept verbatim while unchanged and written from the model once edited; split from the `w:altChunk`/sub-documents row | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | diff --git a/docs/gaps.md b/docs/gaps.md index c80a98ac5..554546ce1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -78,7 +78,7 @@ The same gaps grouped the way the parity documents are, for agents working in on Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real GNOME or KDE session yet. -- **File-format gaps:** #1, #2, #5, #9 (`.doc` only), #14, #19, #23, #26; embedded fonts; glossary/building blocks. +- **File-format gaps:** #1, #2, #5, #9 (`.doc` only), #14, #19, #23, #26; embedded fonts; `w:altChunk` and sub-documents. - **Hardware gaps:** #6, #12, #28; scanner/Continuity Camera insert; KDE soft text (#140). - **Localization gaps:** #8, #11, #16, #17; native-speaker review of every catalog (human). - **Stability and performance gaps:** #3, #20. @@ -89,6 +89,7 @@ The Language dialog reads effective character formatting, including document def | Date | Change | Summary | |---|---|---| +| 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-18 | trivial | #7: Split Cells and Convert Text to Table follow the Word check (#2592) | | 2026-10-16 | trivial | #7: Split Cells and Convert Text to Table dialogs (#2592), 58 modal dialogs (matches ui-parity.md) |