From a7c809e63bdd939b4d8349808a6012a76cd20962 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:48:51 +0200 Subject: [PATCH] Charts, SmartArt and OLE objects are no longer lost when you save a .docx (#333) * Keep charts, SmartArt and OLE objects when saving DOCX (#319) Charts and SmartArt diagrams were drawn since #292 but the writer skipped them, and OLE objects kept only their picture, so opening and saving a document silently dropped them. The reader now keeps each object's own markup (the a:graphic of a chart or diagram, the whole w:object of an OLE object) on the model object, and every part it reaches through relationships (chart part, embedded workbook, colour and style parts; diagram data, layout, quick style, colours and drawing; OLE embedding and picture) as opaque bytes in Document::passthrough, with their content types and relationships in a manifest, as the VBA project already is. The writer emits the object with fresh relationship ids pointing at the re-added parts, keeping each part's own relationship ids and content types. A chart or diagram gets its frame from the object's current size and position; an OLE object gets its new size written into its VML shape. A part whose name the writer uses itself is renamed, and an object pasted more than once gets its own copies of every part (sharing past a size budget). Part counts, relationship counts, markup size and copy bytes are all capped. Co-Authored-By: Claude Opus 5.5 * Keep charts, SmartArt and OLE objects when saving DOCX (#319) Charts and SmartArt diagrams were drawn since #292 but the writer skipped them, and OLE objects kept only their picture, so opening and saving a document silently dropped them. The reader now keeps each object's own markup (the a:graphic of a chart or diagram, the whole w:object of an OLE object) on the model object, and every part it reaches through relationships (chart part, embedded workbook, colour and style parts; diagram data, layout, quick style, colours and drawing; OLE embedding and picture) as opaque bytes in Document::passthrough, with their content types and relationships in a manifest, as the VBA project already is. The writer emits the object with fresh relationship ids pointing at the re-added parts, keeping each part's own relationship ids and content types. A chart or diagram gets its frame from the object's current size and position; an OLE object gets its new size written into its VML shape. A part whose name the writer uses itself is renamed, and an object pasted more than once gets its own copies of every part (sharing past a size budget). Part counts, relationship counts, markup size and copy bytes are all capped. Co-Authored-By: Claude Opus 5.5 * Merge fix: #340's rotation round-trip test builds an Image with #333's ole field Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 + crates/doc/src/graphic.rs | 29 +++ crates/doc/src/para.rs | 4 + crates/docbin/src/lib.rs | 10 +- crates/docx/src/package.rs | 116 ++++++++++ crates/docx/src/read/diagram.rs | 9 + crates/docx/src/read/embed.rs | 140 +++++++++++ crates/docx/src/read/mod.rs | 19 +- crates/docx/src/read/story.rs | 46 +++- crates/docx/src/write/embed.rs | 398 ++++++++++++++++++++++++++++++++ crates/docx/src/write/mod.rs | 18 ++ crates/docx/src/write/story.rs | 38 ++- crates/docx/tests/fixtures.rs | 239 +++++++++++++++++++ crates/docx/tests/roundtrip.rs | 19 +- crates/engine/src/cmd/insert.rs | 11 +- crates/formats/src/model.rs | 2 +- crates/formats/src/tests.rs | 2 +- crates/layout/src/tests.rs | 2 +- crates/layout/src/tests_bidi.rs | 11 +- crates/pdf/src/tests.rs | 21 +- crates/render/src/lib.rs | 40 +++- docs/file-format-parity.md | 13 +- docs/gaps.md | 3 +- 23 files changed, 1152 insertions(+), 41 deletions(-) create mode 100644 crates/docx/src/read/embed.rs create mode 100644 crates/docx/src/write/embed.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2a055d1..afa057e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) | +| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316); v0.4.0 released | | 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released | | 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) | | 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) | @@ -141,6 +143,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) | | 2026-10-10 | trivial | Progress log: free rotation and flips (#332) | | 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) | | 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password | diff --git a/crates/doc/src/graphic.rs b/crates/doc/src/graphic.rs index a2bfae1..c072297 100644 --- a/crates/doc/src/graphic.rs +++ b/crates/doc/src/graphic.rs @@ -19,6 +19,35 @@ pub struct Graphic { pub w: f32, #[serde(default)] pub h: f32, + /// Where the graphic was read from, so saving writes the chart or diagram itself back (not + /// only these items). `None` for graphics made some other way. + #[serde(skip)] + pub source: Option>, +} + +/// What an object read from a file needs to be written back as it was: its own markup in the +/// source format and the package parts that markup refers to. Never edited; WordCraft only moves +/// and resizes the object around it. The parts themselves (bytes, content type and their own +/// relationships) are kept in [`crate::Document::passthrough`] under their part names. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Embedded { + /// The object's markup as a standalone XML element with its namespace declarations (DOCX: + /// the `a:graphic` of a chart or SmartArt diagram, the `w:object` of an OLE object). + pub xml: String, + /// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing). + pub refs: Vec, +} + +/// One relationship an [`Embedded`] object uses. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct EmbeddedRef { + /// The relationship id the markup uses. + pub id: String, + /// Relationship type (a URI). + pub kind: String, + /// The part name (a `Document::passthrough` key), or the URL of an external target. + pub target: String, + pub external: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 68b410d..3fdeef6 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -234,6 +234,10 @@ pub enum InlineObject { /// Crop (left, top, right, bottom) fractions 0..1. #[serde(default)] crop: [f32; 4], + /// The OLE object (embedded or linked file) this picture shows, as read from a file: + /// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`]. + #[serde(skip)] + ole: Option>, }, /// A chart or SmartArt diagram (see [`crate::graphic`]). Graphic { diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index b2c506e..f1ada01 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -407,7 +407,15 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) - match fc_pic.and_then(|at| pics.get(ctx.data, at)) { Some((key, w, h)) => { pb.push('\u{FFFC}', CharProps::default()); - pb.objects.push(InlineObject::Image { media: key, w, h, alt: String::new(), float: Default::default(), crop: [0.0; 4] }); + pb.objects.push(InlineObject::Image { + media: key, + w, + h, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }); } None => pics.dropped(cp), } diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 6cfe5de..c3e3ebc 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -46,6 +46,79 @@ pub const VBA_RELATED: &str = "wordcraft:vbaProject.related"; /// Most parts we carry along with a macro project. pub const MAX_VBA_RELATED: usize = 64; +/// `Document::passthrough` key listing the parts that objects read from a file refer to (charts, +/// SmartArt diagrams, OLE objects: see `wordcraft_doc::graphic::Embedded`), with their content +/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its +/// part name. Not a package part: WordCraft's own bookkeeping. +pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts"; +/// Most parts we carry along for a file's embedded objects. +pub const MAX_EMBEDDED_PARTS: usize = 4096; +/// Most relationships we keep for one such part. +pub const MAX_EMBEDDED_RELS: usize = 1024; +/// Longest markup we keep for one embedded object. +pub const MAX_EMBEDDED_XML: usize = 1024 * 1024; + +/// One part an embedded object needs: its content type and its own relationships (internal +/// targets are part names). +#[derive(Clone, Debug, Default)] +pub struct EmbeddedPart { + pub content_type: String, + pub rels: Vec, +} + +/// The parts kept for embedded objects, by part name (see [`EMBEDDED_PARTS`]). Stored as text: +/// `P\tpart\tcontent type` per part, then `R\tpart\tid\ttype\ttarget\tI|E` per relationship of +/// that part (`E`: `target` is an external URL). +#[derive(Clone, Debug, Default)] +pub struct EmbeddedManifest { + pub parts: BTreeMap, +} + +/// Can `s` be a field of a manifest line? +pub fn manifest_field_ok(s: &str) -> bool { + !s.is_empty() && !s.contains(['\t', '\r', '\n']) +} + +impl EmbeddedManifest { + pub fn parse(bytes: &[u8]) -> EmbeddedManifest { + let mut m = EmbeddedManifest::default(); + for line in String::from_utf8_lossy(bytes).lines() { + let f: Vec<&str> = line.split('\t').collect(); + match f.as_slice() { + ["P", part, ct] if m.parts.len() < MAX_EMBEDDED_PARTS => { + m.parts.entry((*part).to_string()).or_insert_with(|| EmbeddedPart { content_type: (*ct).to_string(), rels: Vec::new() }); + } + ["R", part, id, kind, target, mode] => { + if let Some(p) = m.parts.get_mut(*part) + && p.rels.len() < MAX_EMBEDDED_RELS + && !p.rels.iter().any(|r| r.id == *id) + { + p.rels.push(Rel { id: (*id).to_string(), kind: (*kind).to_string(), target: (*target).to_string(), external: *mode == "E" }); + } + } + _ => {} + } + } + m + } + + pub fn to_bytes(&self) -> Vec { + let mut s = String::new(); + for (part, p) in &self.parts { + if !manifest_field_ok(part) || !manifest_field_ok(&p.content_type) { + continue; + } + s.push_str(&format!("P\t{part}\t{}\n", p.content_type)); + } + for (part, p) in &self.parts { + for r in p.rels.iter().filter(|r| [&r.id, &r.kind, &r.target].iter().all(|f| manifest_field_ok(f))) { + s.push_str(&format!("R\t{part}\t{}\t{}\t{}\t{}\n", r.id, r.kind, r.target, if r.external { "E" } else { "I" })); + } + } + s.into_bytes() + } +} + /// Does relationship type `t` end with `suffix` (ignoring the transitional/strict prefix)? pub fn rel_is(t: &str, full: &str) -> bool { let tail = |s: &str| s.rsplit('/').next().unwrap_or(s).to_string(); @@ -224,6 +297,21 @@ pub fn resolve(dir: &str, target: &str) -> String { parts.join("/") } +/// The relative reference from part `from` to part `to` (both package paths), as a +/// relationship target: `word/document.xml` → `word/charts/chart1.xml` is `charts/chart1.xml`. +pub fn relative(from: &str, to: &str) -> String { + let from_dir: Vec<&str> = from.split('/').filter(|s| !s.is_empty()).collect(); + let from_dir = from_dir.get(..from_dir.len().saturating_sub(1)).unwrap_or(&[]); + let to: Vec<&str> = to.split('/').filter(|s| !s.is_empty()).collect(); + let to_dir = to.get(..to.len().saturating_sub(1)).unwrap_or(&[]); + let common = from_dir.iter().zip(to_dir).take_while(|(a, b)| a == b).count(); + let mut out: Vec<&str> = vec![".."; from_dir.len() - common]; + out.extend(to.get(common..).unwrap_or(&[])); + let s = out.join("/"); + // A relative reference whose first segment has a colon would read as a URI scheme. + if s.split('/').next().is_some_and(|seg| seg.contains(':')) { format!("./{s}") } else { s } +} + /// Build a zip from (name, bytes) entries. pub fn zip_entries(entries: &[(String, Vec)]) -> Result, DocxError> { let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new())); @@ -285,4 +373,32 @@ mod tests { assert_eq!(resolve("", "word/document.xml"), "word/document.xml"); assert_eq!(resolve("word", "../../../x"), "x"); } + + #[test] + fn relative_targets_resolve_back() { + for (from, to, want) in [ + ("word/document.xml", "word/charts/chart1.xml", "charts/chart1.xml"), + ("word/charts/chart1.xml", "word/embeddings/book.xlsx", "../embeddings/book.xlsx"), + ("word/charts/chart1.xml", "word/charts/colors1.xml", "colors1.xml"), + ("word/document.xml", "customXml/item1.xml", "../customXml/item1.xml"), + ("word/document.xml", "word/a:b.bin", "./a:b.bin"), + ] { + let r = relative(from, to); + assert_eq!(r, want); + assert_eq!(resolve(from.rsplit_once('/').map_or("", |(d, _)| d), &r), to); + } + } + + #[test] + fn embedded_manifest_round_trips_and_ignores_junk() { + let mut m = EmbeddedManifest::default(); + let rel = Rel { id: "rId1".into(), kind: "k".into(), target: "word/embeddings/x.xlsx".into(), external: false }; + let url = Rel { id: "rId2".into(), kind: "k".into(), target: "https://example.com/x".into(), external: true }; + m.parts.insert("word/charts/chart1.xml".into(), EmbeddedPart { content_type: "ct".into(), rels: vec![rel, url] }); + let back = EmbeddedManifest::parse(&m.to_bytes()); + let p = &back.parts["word/charts/chart1.xml"]; + assert_eq!((p.content_type.as_str(), p.rels.len(), p.rels[1].external), ("ct", 2, true)); + let junk = EmbeddedManifest::parse(b"R\tnope\trId1\tk\tt\tI\nP\tonly-two\n\t\t\n\xff\xfe"); + assert!(junk.parts.is_empty()); + } } diff --git a/crates/docx/src/read/diagram.rs b/crates/docx/src/read/diagram.rs index c04e52a..c866574 100644 --- a/crates/docx/src/read/diagram.rs +++ b/crates/docx/src/read/diagram.rs @@ -512,6 +512,15 @@ impl Reader<'_> { drawing.map(|r| r.target.clone()) } + /// The id of the relationship to a SmartArt diagram's drawing part, as its data part names it + /// (`dsp:dataModelExt relId`): the id the parts use, not the markup, so it's kept with them. + pub(super) fn diagram_drawing_rel(&mut self, gd: &El, rels: &Rels) -> Option { + let dm = gd.child("dgm:relIds")?.attr("r:dm")?; + let data = super::part_of(rels, dm, rt::DIAGRAM_DATA)?; + let id = self.graphic_part(&data)?.find("dsp:dataModelExt")?.attr("relId")?.to_string(); + rels.by_id(&id).is_some_and(|r| !r.external && rel_is(&r.kind, rt::DIAGRAM_DRAWING)).then_some(id) + } + /// The items of the SmartArt drawing part at `path`, fitted to `w` × `h` points. pub(super) fn diagram_drawing(&mut self, path: &str, w: f32, h: f32) -> Vec { let Some(root) = self.graphic_part(path) else { return Vec::new() }; diff --git a/crates/docx/src/read/embed.rs b/crates/docx/src/read/embed.rs new file mode 100644 index 0000000..521660c --- /dev/null +++ b/crates/docx/src/read/embed.rs @@ -0,0 +1,140 @@ +//! Objects kept for writing back: charts, SmartArt diagrams and OLE objects. +//! +//! WordCraft draws charts and diagrams and shows an OLE object's picture, but doesn't edit them. +//! So that saving doesn't lose them, the reader keeps each one's own markup (an `a:graphic` or a +//! `w:object`) as a [`wordcraft_doc::graphic::Embedded`] on the object, and every part that markup +//! reaches through relationships (chart, embedded workbook, colours and style; diagram data, layout, +//! quick style, colours and drawing; OLE embedding and its picture…) as opaque bytes in +//! `Document::passthrough`, with their content types and relationships in an +//! [`EmbeddedManifest`](crate::package::EmbeddedManifest). + +use std::sync::Arc; + +use wordcraft_doc::graphic::{Embedded, EmbeddedRef}; + +use super::Reader; +use crate::package::{ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok}; +use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc}; + +/// Most relationship ids one object's markup may use. +const MAX_IDS: usize = 64; + +impl Reader<'_> { + /// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers + /// to, so the object can be written back. `extra` names relationships the object's parts use + /// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part). + /// `None` when it can't be written back faithfully: a relationship it uses is missing, a part + /// is missing, or it's too big. + pub(super) fn keep_embedded(&mut self, el: &El, rels: &Rels, extra: &[String]) -> Option> { + let mut ids = Vec::new(); + rel_ids(el, &mut ids, 0)?; + let mut refs = Vec::new(); + for id in &ids { + let rel = rels.by_id(id)?; + if !rel.external && !self.keep_part_tree(&rel.target) { + return None; + } + refs.push(EmbeddedRef { id: id.clone(), kind: rel.kind.clone(), target: rel.target.clone(), external: rel.external }); + } + for id in extra.iter().filter(|id| !ids.contains(id)) { + if let Some(rel) = rels.by_id(id).filter(|r| !r.external) + && self.keep_part_tree(&rel.target) + { + refs.push(EmbeddedRef { id: id.clone(), kind: rel.kind.clone(), target: rel.target.clone(), external: false }); + } + } + let xml = standalone_xml(el)?; + Some(Arc::new(Embedded { xml, refs })) + } + + /// Keep part `root` and every part it reaches through its relationships. False when `root` + /// itself can't be kept. + fn keep_part_tree(&mut self, root: &str) -> bool { + if self.embedded.parts.contains_key(root) { + return true; + } + if !self.keep_part(root) { + return false; + } + let mut todo = vec![root.to_string()]; + while let Some(part) = todo.pop() { + let mut kept: Vec = Vec::new(); + for r in self.pkg.rels(&part).list { + if kept.len() >= MAX_EMBEDDED_RELS { + log::warn!("docx: part {part} has more than {MAX_EMBEDDED_RELS} relationships; the rest are dropped"); + break; + } + if ![&r.id, &r.kind, &r.target].iter().all(|f| manifest_field_ok(f)) || kept.iter().any(|k| k.id == r.id) { + continue; + } + if !r.external && !self.embedded.parts.contains_key(&r.target) { + if !self.keep_part(&r.target) { + continue; + } + todo.push(r.target.clone()); + } + kept.push(r); + } + if let Some(p) = self.embedded.parts.get_mut(&part) { + p.rels = kept; + } + } + true + } + + /// Keep one part's bytes and content type (not its relationships). + fn keep_part(&mut self, part: &str) -> bool { + let lower = part.to_ascii_lowercase(); + if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) { + log::warn!("docx: not carrying part {part:?} for an embedded object"); + return false; + } + if self.embedded.parts.len() >= MAX_EMBEDDED_PARTS { + log::warn!("docx: more than {MAX_EMBEDDED_PARTS} parts for embedded objects; the rest are dropped"); + return false; + } + let Some(bytes) = self.pkg.get(part) else { + log::warn!("docx: part {part} of an embedded object is missing"); + return false; + }; + let pkg = self.pkg; + let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(part).unwrap_or("application/octet-stream"); + let content_type = if manifest_field_ok(ct) { ct.to_string() } else { "application/octet-stream".to_string() }; + self.doc.passthrough.entry(part.to_string()).or_insert_with(|| Arc::new(bytes.to_vec())); + self.embedded.parts.insert(part.to_string(), crate::package::EmbeddedPart { content_type, rels: Vec::new() }); + true + } +} + +/// The relationship ids `e` uses (attributes in the relationships namespace, and VML's +/// `o:relid`), in document order. `None` past [`MAX_IDS`]. +fn rel_ids(e: &El, out: &mut Vec, depth: usize) -> Option<()> { + if depth > MAX_DEPTH { + return Some(()); + } + for (k, v) in &e.attrs { + if (k.starts_with("r:") || k == "o:relid") && !out.contains(v) { + if out.len() >= MAX_IDS { + return None; + } + out.push(v.clone()); + } + } + for c in e.els() { + rel_ids(c, out, depth + 1)?; + } + Some(()) +} + +/// `e` as a standalone element: its XML with the namespace declarations its canonical prefixes +/// need. `None` when too long, or in a namespace we can't name. +fn standalone_xml(e: &El) -> Option { + let xml = e.to_xml(); + let rest = xml.get(1 + e.name.len()..).filter(|_| !xml.is_empty())?; + let mut s = format!("<{}", e.name); + for (p, u) in NAMESPACES { + s.push_str(&format!(" xmlns:{p}=\"{}\"", esc(u))); + } + s.push_str(rest); + (s.len() <= MAX_EMBEDDED_XML).then_some(s) +} diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 5e45830..a754b84 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -3,6 +3,7 @@ mod chart; mod diagram; mod drawing_color; +mod embed; mod math; mod props; mod story; @@ -20,7 +21,7 @@ use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts}; use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, PartKind, Revision, RevisionKind}; use crate::DocxError; -use crate::package::{ContentTypes, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt}; +use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt}; use crate::units::{flag, int, on_off, tw, u32_of}; use crate::xml::El; pub(crate) use props::PropCtx; @@ -53,6 +54,12 @@ pub(crate) struct Reader<'p> { graphics: HashMap<(GraphicKind, String, u32, u32), Arc>, /// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]). graphic_budget: usize, + /// The main document part's name. + main: String, + /// The package's content types, read when first needed. + content_types: Option, + /// Parts kept for charts, diagrams and OLE objects (see `embed`). + embedded: EmbeddedManifest, } /// The package path that internal relationship `id` points at, if it has type `kind` (an `rt` @@ -101,6 +108,9 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { graphic_parts: HashMap::new(), graphics: HashMap::new(), graphic_budget: MAX_GRAPHIC_WORK, + main: main.clone(), + content_types: None, + embedded: EmbeddedManifest::default(), }; r.pc.major_font = r.doc.settings.major_font.clone(); r.pc.minor_font = r.doc.settings.minor_font.clone(); @@ -168,6 +178,10 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec())); r.read_vba_related(&v); } + if !r.embedded.parts.is_empty() { + let manifest = r.embedded.to_bytes(); + r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest)); + } let mut doc = r.doc; doc.ensure_nonempty(); Ok(doc) @@ -246,7 +260,8 @@ impl Reader<'_> { /// content types (see [`VBA_RELATED`]). Parts outside `word/`, relationship parts, the project /// itself and missing targets are skipped (logged); at most [`MAX_VBA_RELATED`] are kept. fn read_vba_related(&mut self, project: &str) { - let types = ContentTypes::read(self.pkg); + let pkg = self.pkg; + let types = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)); let mut manifest = String::new(); let mut kept = 0usize; for rel in self.pkg.rels(project).list.iter().filter(|r| !r.external) { diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 5a2b8ee..5d7907c 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; -use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind}; +use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::props::{CharProps, Rgb}; use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table}; @@ -466,11 +466,16 @@ impl Reader<'_> { self.emit_obj(sc, pb, o, props); } } - "w:pict" | "w:object" => { + "w:pict" => { if let Some(o) = self.read_vml(sc, k, rels) { self.emit_obj(sc, pb, o, props); } } + "w:object" => { + if let Some(o) = self.read_object(sc, k, rels) { + self.emit_obj(sc, pb, o, props); + } + } "w:footnoteReference" | "w:endnoteReference" => { let foot = k.name == "w:footnoteReference"; let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN); @@ -662,7 +667,14 @@ impl Reader<'_> { None }; if let Some(kind) = graphic_kind { - let graphic = self.graphic(kind, gd, rels, w, h); + // Kept so saving writes the chart or diagram back: the frame around it is written + // from the object's (possibly moved or resized) size and position. + let extra: Vec = match kind { + GraphicKind::Chart => Vec::new(), + GraphicKind::Diagram => self.diagram_drawing_rel(gd, rels).into_iter().collect(), + }; + let source = c.child("a:graphic").and_then(|g| self.keep_embedded(g, rels, &extra)); + let graphic = self.graphic(kind, gd, rels, w, h, source); float.effect = float.effect.map(|e| e.max(0.0)); return Some(InlineObject::Graphic { w, h, alt, float, graphic }); } @@ -688,19 +700,19 @@ impl Reader<'_> { /// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word /// stores beside them. Built once per part and size; once the file's graphic budget is spent, /// later ones are empty. - fn graphic(&mut self, kind: GraphicKind, gd: &El, rels: &Rels, w: f32, h: f32) -> Arc { - let source = match kind { + fn graphic(&mut self, kind: GraphicKind, gd: &El, rels: &Rels, w: f32, h: f32, source: Option>) -> Arc { + let part = match kind { GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)), GraphicKind::Diagram => self.diagram_part(gd, rels), }; - let Some(path) = source else { return Arc::new(Graphic { kind, items: Vec::new(), w, h }) }; + let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source }) }; let key = (kind, path, w.to_bits(), h.to_bits()); if let Some(g) = self.graphics.get(&key) { return g.clone(); } let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) }; self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items)); - let g = Arc::new(Graphic { kind, items, w, h }); + let g = Arc::new(Graphic { kind, items, w, h, source }); self.graphics.insert(key, g.clone()); g } @@ -731,7 +743,7 @@ impl Reader<'_> { } } } - Some(InlineObject::Image { media, w, h, alt, float, crop }) + Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None }) } /// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups @@ -865,7 +877,7 @@ impl Reader<'_> { let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?; let media = self.media_for(rels, id)?; let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string(); - return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4] }); + return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None }); } if let Some(t) = shape.find("w:txbxContent") && sc.story_depth < MAX_STORY_DEPTH @@ -888,6 +900,22 @@ impl Reader<'_> { None } + /// An OLE object (`w:object`, ECMA-376 §17.3.3.19): shown as its picture (VML, or a DrawingML + /// picture in `w:drawing`), and kept whole so saving writes the object back. + fn read_object(&mut self, sc: &mut StoryCtx, obj: &El, rels: &Rels) -> Option { + let mut o = match self.read_vml(sc, obj, rels) { + Some(o) => o, + None => { + let d = obj.child("w:drawing")?; + self.read_drawing(sc, d, rels)? + } + }; + if let InlineObject::Image { ole, .. } = &mut o { + *ole = self.keep_embedded(obj, rels, &[]); + } + Some(o) + } + // ---- tables ---- fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option { diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs new file mode 100644 index 0000000..5e54116 --- /dev/null +++ b/crates/docx/src/write/embed.rs @@ -0,0 +1,398 @@ +//! Writing back charts, SmartArt diagrams and OLE objects read from a file (see `read::embed`). +//! +//! Each written object gets its kept markup with fresh relationship ids, pointing at copies of the +//! parts it needs. The first object to use a part writes it under its original name (unless that +//! name is one this writer uses for something else: then it's renamed). An object written again +//! (copied and pasted in WordCraft) gets its own renamed copies of every part, so the copies stay +//! independent in Word; past [`MAX_COPY_BYTES`] of such copies, later ones share the first's parts. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use wordcraft_doc::graphic::Embedded; +use wordcraft_doc::para::Float; + +use super::PartRels; +use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, VBA_PROJECT_PART, VBA_RELATED, relative}; +use crate::units::emu; +use crate::xml::{self, El, MAX_DEPTH}; + +/// Most bytes written for copies of parts (objects pasted more than once). +const MAX_COPY_BYTES: u64 = 256 * 1024 * 1024; +/// Most bytes of kept markup written for all objects (objects can share one kept markup). +const MAX_MARKUP_BYTES: u64 = 128 * 1024 * 1024; +/// What one part copy costs at least against [`MAX_COPY_BYTES`] (its relationships, entry…). +const PART_COST: u64 = 4096; + +/// Where the stories' parts live: every story this writer makes is a part in `word/`. +const STORY_PART: &str = "word/document.xml"; + +/// The embedded-object parts of a package being written. +pub(crate) struct EmbedWriter<'d> { + manifest: EmbeddedManifest, + passthrough: &'d BTreeMap>>, + /// Part names in use (lower case): this writer's own, and parts written so far. + taken: HashSet, + /// Next suffix to try when renaming a part. + next: HashMap, + /// Original part name → name written, for parts written by an object's first copy. + first: HashMap, + /// Budget left for copies. + copy_bytes: u64, + /// Budget left for kept markup. + markup_bytes: u64, + /// Parts to write: (name, bytes, content type, relationships). + pub parts: Vec<(String, Vec, String, PartRels)>, +} + +impl<'d> EmbedWriter<'d> { + pub fn new(doc: &'d wordcraft_doc::Document) -> EmbedWriter<'d> { + let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); + let mut taken: HashSet = [ + "word/document.xml", + "word/styles.xml", + "word/numbering.xml", + "word/settings.xml", + "word/theme/theme1.xml", + "word/footnotes.xml", + "word/endnotes.xml", + "word/comments.xml", + "word/commentsextended.xml", + "[content_types].xml", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + taken.insert(VBA_PROJECT_PART.to_ascii_lowercase()); + // The macro project's parts keep their names (see `write_as`). + if let Some(m) = doc.passthrough.get(VBA_RELATED) { + for line in String::from_utf8_lossy(m).lines() { + if let Some(path) = line.split('\t').nth(1) { + taken.insert(path.to_ascii_lowercase()); + } + } + } + EmbedWriter { + manifest, + passthrough: &doc.passthrough, + taken, + next: HashMap::new(), + first: HashMap::new(), + copy_bytes: MAX_COPY_BYTES, + markup_bytes: MAX_MARKUP_BYTES, + parts: Vec::new(), + } + } + + /// Reserve the names of the media files this writer writes. + pub fn reserve_media<'a>(&mut self, files: impl Iterator) { + for f in files { + self.taken.insert(format!("word/media/{f}").to_ascii_lowercase()); + } + } + + /// Names this writer may give its own parts later (headers, footers, package parts). + fn reserved(lower: &str) -> bool { + lower.ends_with(".rels") + || lower.starts_with("docprops/") + || lower.starts_with("_rels/") + || ((lower.starts_with("word/header") || lower.starts_with("word/footer")) && lower.ends_with(".xml")) + } + + /// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`. + fn alloc(&mut self, orig: &str) -> String { + let lower = orig.to_ascii_lowercase(); + if !Self::reserved(&lower) && self.taken.insert(lower) { + return orig.to_string(); + } + let file_at = orig.rfind('/').map_or(0, |i| i + 1); + let (stem, ext) = match orig.rfind('.').filter(|i| *i > file_at) { + Some(i) => (orig.get(..i).unwrap_or(orig), orig.get(i..).unwrap_or("")), + None => (orig, ""), + }; + loop { + let n = self.next.entry(orig.to_string()).or_insert(1); + *n = n.saturating_add(1); + let name = format!("{stem}_{n}{ext}"); + let lower = name.to_ascii_lowercase(); + if !Self::reserved(&lower) && self.taken.insert(lower) { + return name; + } + } + } + + /// Can part `name` be written (bytes and manifest entry both kept)? + fn available(&self, name: &str) -> bool { + self.manifest.parts.contains_key(name) && self.passthrough.contains_key(name) + } + + /// 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(); + let mut todo: Vec<&str> = roots.to_vec(); + let mut cost = 0u64; + while let Some(p) = todo.pop() { + if !seen.insert(p) || seen.len() > MAX_EMBEDDED_PARTS { + continue; + } + cost = cost.saturating_add(self.passthrough.get(p).map_or(0, |b| b.len() as u64)).saturating_add(PART_COST); + if let Some(info) = self.manifest.parts.get(p) { + todo.extend(info.rels.iter().filter(|r| !r.external).map(|r| r.target.as_str())); + } + } + cost + } + + /// Write part `root` and the parts it reaches, naming them through `memo` (original → written + /// name; parts already in it aren't written again). Returns `root`'s written name and adds + /// the indices of the parts written to `written`. + fn copy_tree(&mut self, root: &str, memo: &mut HashMap, written: &mut Vec) -> Option { + if let Some(n) = memo.get(root) { + return Some(n.clone()); + } + if !self.available(root) { + return None; + } + let name = self.alloc(root); + memo.insert(root.to_string(), name.clone()); + let mut todo = vec![root.to_string()]; + while let Some(orig) = todo.pop() { + let (Some(info), Some(bytes)) = (self.manifest.parts.get(&orig).cloned(), self.passthrough.get(&orig)) else { continue }; + let Some(new) = memo.get(&orig).cloned() else { continue }; + let mut rels = PartRels::default(); + for r in &info.rels { + if r.external { + rels.add_with_id(&r.id, &r.kind, &r.target, true); + continue; + } + let target = match memo.get(&r.target) { + Some(t) => t.clone(), + None if self.available(&r.target) => { + let t = self.alloc(&r.target); + memo.insert(r.target.clone(), t.clone()); + todo.push(r.target.clone()); + t + } + // A part that wasn't kept: leave the relationship out. + None => continue, + }; + rels.add_with_id(&r.id, &r.kind, &relative(&new, &target), false); + } + let ct = if info.content_type.is_empty() { "application/octet-stream".to_string() } else { info.content_type }; + written.push(self.parts.len()); + self.parts.push((new, bytes.to_vec(), ct, rels)); + } + Some(name) + } +} + +impl super::Writer<'_> { + /// The markup for an object kept from a file, with its relationships added to `rels` (the + /// story part's) and the parts it needs queued for writing. `resize`, for an OLE object, writes + /// the object's current size and position into its own markup (a chart or diagram's frame is + /// written by the caller). `None` when it can't be written back (its parts are gone). + pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float)>) -> Option { + let Some(left) = self.embeds.markup_bytes.checked_sub(src.xml.len() as u64) else { + log::warn!("docx: too much embedded-object markup; the rest is left out"); + return None; + }; + self.embeds.markup_bytes = left; + let mut root = xml::parse(src.xml.as_bytes()).ok()?; + let mut used = Vec::new(); + rel_attr_values(&root, &mut used, 0); + // Every relationship the markup uses must be writable, or the object would be broken. + let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target))); + if !used.iter().all(writable) { + log::warn!("docx: an embedded object's parts are missing; it is left out"); + return None; + } + let roots: Vec<&str> = src.refs.iter().filter(|r| !r.external).map(|r| r.target.as_str()).collect(); + // Written before (a copy of this object): give this one its own parts while the budget lasts. + let again = roots.iter().any(|r| self.embeds.first.contains_key(*r)); + let cost = if again { self.embeds.tree_cost(&roots) } else { 0 }; + let fresh = again && cost <= self.embeds.copy_bytes; + if fresh { + self.embeds.copy_bytes -= cost; + } + let mut memo = if fresh { HashMap::new() } else { std::mem::take(&mut self.embeds.first) }; + let mut written = Vec::new(); + let mut ids: HashMap = HashMap::new(); + for r in &src.refs { + let new_id = if r.external { + Some(rels.add(&r.kind, &r.target, true)) + } else { + self.embeds.copy_tree(&r.target, &mut memo, &mut written).map(|name| rels.add(&r.kind, &relative(STORY_PART, &name), false)) + }; + if let Some(id) = new_id { + ids.insert(r.id.clone(), id); + } + } + if !fresh { + self.embeds.first = memo; + } + if used.iter().any(|u| !ids.contains_key(u)) { + log::warn!("docx: an embedded object lost a part; it is left out"); + return None; + } + // A SmartArt data part names its drawing's relationship (of the story part) by id. + for i in written { + if let Some((_, bytes, ct, _)) = self.embeds.parts.get_mut(i) + && ct.contains("diagramData") + { + *bytes = patch_rel_ids(bytes, &ids); + } + } + set_rel_ids(&mut root, &ids, 0); + if let Some((w, h, float)) = resize { + self.resize_object(&mut root, w, h, float, 0); + } + Some(root.to_xml()) + } + + /// Write an OLE object's size (and position, when floating) into its VML shape and + /// DrawingML extent, and give its drawing a fresh `wp:docPr` id. + fn resize_object(&mut self, e: &mut El, w: f32, h: f32, float: &Float, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match e.name.as_str() { + "v:shape" => { + if let Some((_, style)) = e.attrs.iter_mut().find(|(k, _)| k == "style") { + *style = vml_style_with(style, w, h, float); + } + } + "wp:extent" => { + for (k, v) in e.attrs.iter_mut() { + match k.as_str() { + "cx" => *v = emu(w.max(0.0)), + "cy" => *v = emu(h.max(0.0)), + _ => {} + } + } + } + "wp:docPr" => { + let id = self.next_docpr(); + if let Some((_, v)) = e.attrs.iter_mut().find(|(k, _)| k == "id") { + *v = id; + } + } + _ => {} + } + for k in e.kids.iter_mut() { + if let xml::Node::El(c) = k { + self.resize_object(c, w, h, float, depth + 1); + } + } + } +} + +/// Is attribute `k` a relationship id (see `read::embed::rel_ids`)? +fn is_rel_attr(k: &str) -> bool { + k.starts_with("r:") || k == "o:relid" +} + +fn rel_attr_values(e: &El, out: &mut Vec, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for (k, v) in &e.attrs { + if is_rel_attr(k) && !out.contains(v) { + out.push(v.clone()); + } + } + for c in e.els() { + rel_attr_values(c, out, depth + 1); + } +} + +fn set_rel_ids(e: &mut El, ids: &HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for (k, v) in e.attrs.iter_mut() { + if is_rel_attr(k) + && let Some(n) = ids.get(v.as_str()) + { + *v = n.clone(); + } + } + for k in e.kids.iter_mut() { + if let xml::Node::El(c) = k { + set_rel_ids(c, ids, depth + 1); + } + } +} + +/// `bytes` with each `relId="old"` naming a remapped relationship rewritten to its new id. +fn patch_rel_ids(bytes: &[u8], ids: &HashMap) -> Vec { + const KEY: &[u8] = b"relId=\""; + let mut out = Vec::with_capacity(bytes.len()); + let mut rest = bytes; + while let Some(at) = rest.windows(KEY.len()).position(|w| w == KEY) { + let (head, tail) = rest.split_at(at + KEY.len()); + out.extend_from_slice(head); + let end = tail.iter().position(|b| *b == b'"').unwrap_or(tail.len()); + let (old, after) = tail.split_at(end); + match std::str::from_utf8(old).ok().and_then(|o| ids.get(o)) { + Some(new) => out.extend_from_slice(new.as_bytes()), + None => out.extend_from_slice(old), + } + rest = after; + } + out.extend_from_slice(rest); + out +} + +/// A VML `style` with the object's size, and its offset when it floats (`position:absolute`). +fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String { + let absolute = style + .split(';') + .filter_map(|d| d.split_once(':')) + .any(|(k, v)| k.trim().eq_ignore_ascii_case("position") && v.trim().eq_ignore_ascii_case("absolute")); + let mut out: Vec = style + .split(';') + .filter(|d| { + let key = d.split_once(':').map_or("", |(k, _)| k).trim().to_ascii_lowercase(); + !d.trim().is_empty() + && !matches!(key.as_str(), "width" | "height") + && !(absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top")) + }) + .map(|d| d.trim().to_string()) + .collect(); + if absolute { + out.push(format!("margin-left:{}pt", pt(float.x))); + out.push(format!("margin-top:{}pt", pt(float.y))); + } + out.push(format!("width:{}pt", pt(w.max(0.0)))); + out.push(format!("height:{}pt", pt(h.max(0.0)))); + out.join(";") +} + +/// Points for a VML style: finite, bounded, at most two decimals. +fn pt(v: f32) -> String { + let v = wordcraft_geom::finite(v).clamp(-crate::units::MAX_LEN_PT, crate::units::MAX_LEN_PT); + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_part_drawing_ids_are_rewritten_once() { + let ids: HashMap = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect(); + let out = patch_rel_ids(br#" bool { self.list.is_empty() } @@ -84,6 +93,8 @@ pub(crate) struct Writer<'d> { used_media: std::collections::BTreeSet, /// Bounds writing text boxes inside text boxes (as layout shows them). boxes: wordcraft_doc::BoxBudget, + /// Charts, diagrams and OLE objects kept from a file: the parts they need. + embeds: embed::EmbedWriter<'d>, } const CT_WML: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml."; @@ -125,8 +136,10 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { toc_end_here: false, used_media: Default::default(), boxes: wordcraft_doc::BoxBudget::default(), + embeds: embed::EmbedWriter::new(doc), }; wr.assign_media(); + wr.embeds.reserve_media(wr.media_files.values()); let mut entries: Vec<(String, Vec)> = Vec::new(); let mut overrides: Vec<(String, String)> = Vec::new(); let mut rels = PartRels::default(); @@ -315,6 +328,11 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { } } + // Parts of charts, diagrams and OLE objects kept from a file, as the objects written need them. + for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) { + push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels); + } + // The macro project and the parts it relates to (VBA data, signatures…), verbatim. A // macro-free package can't hold them (Word drops them too). if let Some(vba) = doc.passthrough.get(VBA_PROJECT_PART).filter(|b| !b.is_empty()) { diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index cde0706..65aaa4d 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -370,9 +370,41 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } - // Charts and diagrams aren't written back yet. - InlineObject::Graphic { .. } => {} - InlineObject::Image { media, w: iw, h: ih, alt, float, .. } => { + // A chart or diagram read from a file: its frame from the object's size and position, + // the graphic inside as read. Ones made some other way have nothing to write. + InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => { + let Some(src) = graphic.source.as_deref() else { return }; + let Some(inner) = self.embedded_xml(src, rels, None) else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = match graphic.kind { + wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), + wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), + }; + self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt); + w.empty("wp:cNvGraphicFramePr", &[]); + w.raw(&inner); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } + InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => { + // An OLE object read from a file: the object itself, at its current size. + if let Some(src) = ole.as_deref() + && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float))) + { + self.rev_open(w, props); + w.open("w:r", &[]); + rpr(w, props); + w.raw(&obj); + w.close("w:r"); + self.rev_close(w, props); + return; + } let Some(file) = self.media_files.get(media).cloned() else { return }; self.rev_open(w, props); w.open("w:r", &[]); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 632600c..1b54fbc 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -700,6 +700,245 @@ fn hostile_vml_style_values_stay_finite() { assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0)); } +// ---- charts and OLE objects written back (#319) ---- + +const CHART_XML: &str = r#"12"#; +/// Stands in for the chart's workbook: opaque bytes, never opened. +const WORKBOOK: &str = "PK\u{3}\u{4} not really a workbook"; +const COLORS_XML: &str = r#""#; +const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; +const CT_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + +/// A package holding one inline chart (3 × 1.5 in) with its workbook and colour style parts. +fn chart_docx() -> Vec { + let body = r#"Sales"#; + let chart_rels = r#""#; + let types = format!( + r#""# + ); + docx( + body, + &[("rId7", "chart", "charts/chart1.xml")], + &[ + ("[Content_Types].xml", &types), + ("word/charts/chart1.xml", CHART_XML), + ("word/charts/_rels/chart1.xml.rels", chart_rels), + ("word/charts/colors1.xml", COLORS_XML), + ("word/embeddings/Microsoft_Excel_Worksheet.xlsx", WORKBOOK), + ], + ) +} + +/// The entries of a zip, by name. +fn unzip(bytes: &[u8]) -> std::collections::BTreeMap> { + use std::io::Read; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = std::collections::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(); + out.insert(f.name().to_string(), b); + } + out +} + +fn text(files: &std::collections::BTreeMap>, name: &str) -> String { + String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap() +} + +/// Values of attribute `attr` on the elements named `el` (in written order). +fn attr_values(xml: &str, el: &str, attr: &str) -> Vec { + let start = |s: &str| format!(" {}", s.split('>').next().unwrap_or("")); + xml.split(&format!("<{el} ")) + .skip(1) + .filter_map(|s| start(s).split(&format!(" {attr}=\"")).nth(1)?.split('"').next().map(str::to_string)) + .collect() +} + +/// (type, target) of relationship `id` in a relationships part. +fn rel(rels: &str, id: &str) -> (String, String) { + let r = rels.split(" String { + let mut parts: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect(); + for seg in target.split('/') { + match seg { + ".." => { + parts.pop(); + } + "." | "" => {} + s => parts.push(s), + } + } + parts.join("/") +} + +/// Checks a written chart: its part and the parts it relates to are all in the package, with the +/// original bytes and content types. Returns the chart's part name. +fn check_chart(files: &std::collections::BTreeMap>, rid: &str) -> String { + let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), rid); + assert!(kind.ends_with("/chart"), "{kind}"); + let chart = part_at("word", &target); + assert_eq!(text(files, &chart), CHART_XML); + let (dir, file) = chart.rsplit_once('/').unwrap(); + let chart_rels = text(files, &format!("{dir}/_rels/{file}.rels")); + let (kind, wb) = rel(&chart_rels, "rId1"); + assert!(kind.ends_with("/package"), "{kind}"); + let wb = part_at(dir, &wb); + assert_eq!(files.get(&wb).map(Vec::as_slice), Some(WORKBOOK.as_bytes()), "{wb}"); + let (kind, colors) = rel(&chart_rels, "rId2"); + assert!(kind.ends_with("/chartColorStyle"), "{kind}"); + assert_eq!(text(files, &part_at(dir, &colors)), COLORS_XML); + let types = text(files, "[Content_Types].xml"); + assert!(types.contains(&format!(r#"PartName="/{chart}" ContentType="{CT_CHART}""#)), "{types}"); + assert!(types.contains(&format!(r#"PartName="/{wb}" ContentType="{CT_XLSX}""#)), "{types}"); + chart +} + +fn graphics(d: &Document) -> Vec { + paras(d).iter().flat_map(|p| p.objects.iter()).filter(|o| matches!(o, InlineObject::Graphic { .. })).cloned().collect() +} + +#[test] +fn chart_round_trips_with_its_parts() { + let d = wordcraft_docx::read(&chart_docx()).unwrap(); + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + let body = text(&files, "word/document.xml"); + let ids = attr_values(&body, "c:chart", "r:id"); + assert_eq!(ids.len(), 1, "{body}"); + assert_eq!(check_chart(&files, &ids[0]), "word/charts/chart1.xml"); + assert_eq!(attr_values(&body, "wp:extent", "cx"), ["2743200"]); + + // Read back: still drawn, and a second save still carries it. + let back = wordcraft_docx::read(&out).unwrap(); + let g = graphics(&back); + let [InlineObject::Graphic { w, graphic, .. }] = g.as_slice() else { panic!("{g:?}") }; + assert_eq!(*w, 216.0); + assert!(!graphic.items.is_empty() && graphic.source.is_some()); + let again = unzip(&wordcraft_docx::write(&back).unwrap()); + let ids = attr_values(&text(&again, "word/document.xml"), "c:chart", "r:id"); + check_chart(&again, &ids[0]); +} + +#[test] +fn moved_and_copied_chart_keeps_its_parts() { + let d = wordcraft_docx::read(&chart_docx()).unwrap(); + let g = graphics(&d); + let [InlineObject::Graphic { alt, graphic, .. }] = g.as_slice() else { panic!("{g:?}") }; + // Resized and dragged to float on the page in WordCraft, then copied and pasted. + let float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 36.0, y: 18.0, ..Default::default() }; + let moved = InlineObject::Graphic { w: 288.0, h: 144.0, alt: alt.clone(), float, graphic: graphic.clone() }; + let mut p = Paragraph::new(); + p.insert_object(0, moved.clone(), &Default::default()).unwrap(); + p.insert_object(p.len(), moved, &Default::default()).unwrap(); + let mut doc = d.clone(); + doc.body = vec![wordcraft_doc::para_block(p)]; + + let out = wordcraft_docx::write(&doc).unwrap(); + let files = unzip(&out); + let body = text(&files, "word/document.xml"); + assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"], "the new size"); + assert_eq!(body.matches("457200").count(), 2, "the new position"); + let ids = attr_values(&body, "c:chart", "r:id"); + assert_eq!(ids.len(), 2); + let charts: Vec = ids.iter().map(|id| check_chart(&files, id)).collect(); + assert_ne!(charts[0], charts[1], "each copy has its own chart part"); + + let back = wordcraft_docx::read(&out).unwrap(); + let g = graphics(&back); + assert_eq!(g.len(), 2); + for o in &g { + let InlineObject::Graphic { w, float, graphic, .. } = o else { panic!() }; + assert_eq!((*w, float.wrap, float.x), (288.0, Wrap::Square, 36.0)); + assert!(!graphic.items.is_empty() && graphic.source.is_some()); + } +} + +#[test] +fn ole_object_round_trips() { + let body = r##""##; + let picture = "\u{89}PNG stand-in"; + let ole = "\u{d0}\u{cf} opaque OLE storage"; + let types = r#""#; + let bytes = docx( + body, + &[("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")], + &[("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)], + ); + let d = wordcraft_docx::read(&bytes).unwrap(); + let Some(InlineObject::Image { w, ole: Some(_), .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) }; + assert_eq!(*w, 72.0); + + // Resized in WordCraft. + let mut doc = d.clone(); + if let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut) + && let Some(InlineObject::Image { w, h, .. }) = p.objects.first_mut() + { + (*w, *h) = (144.0, 72.0); + } + let out = wordcraft_docx::write(&doc).unwrap(); + let files = unzip(&out); + let body = text(&files, "word/document.xml"); + let rels = text(&files, "word/_rels/document.xml.rels"); + assert_eq!(attr_values(&body, "v:shape", "style"), ["width:144pt;height:72pt"]); + let [ole_id] = attr_values(&body, "o:OLEObject", "r:id").try_into().unwrap(); + let (kind, target) = rel(&rels, &ole_id); + assert!(kind.ends_with("/oleObject"), "{kind}"); + let ole_part = part_at("word", &target); + assert_eq!(files.get(&ole_part).map(Vec::as_slice), Some(ole.as_bytes())); + assert!( + text(&files, "[Content_Types].xml") + .contains(&format!(r#"PartName="/{ole_part}" ContentType="application/vnd.openxmlformats-officedocument.oleObject""#)) + ); + let [pic_id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap(); + let (kind, target) = rel(&rels, &pic_id); + assert!(kind.ends_with("/image"), "{kind}"); + assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes())); + assert!(!body.contains(""), "the object, not a plain picture"); + + let back = wordcraft_docx::read(&out).unwrap(); + assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. }))); +} + +#[test] +fn hostile_embedded_relationships_stay_bounded() { + // The chart relates to itself, the main document, a relationships part and a missing part; + // another drawing uses an id with no relationship. Reading and saving neither loop nor carry + // those along, and the chart is still written back. + let chart_rels = r#""#; + let drawing = |id: &str| { + format!( + r#""# + ) + }; + let body = format!("{}{}", drawing("rId7"), drawing("rIdNowhere")); + let bytes = docx( + &body, + &[("rId7", "chart", "charts/chart1.xml")], + &[("word/charts/chart1.xml", CHART_XML), ("word/charts/_rels/chart1.xml.rels", chart_rels)], + ); + let d = wordcraft_docx::read(&bytes).unwrap(); + let g = graphics(&d); + assert_eq!(g.len(), 2); + let sources: Vec = g.iter().map(|o| matches!(o, InlineObject::Graphic { graphic, .. } if graphic.source.is_some())).collect(); + assert_eq!(sources, [true, false], "a dangling id can't be written back"); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + let body = text(&files, "word/document.xml"); + let [id] = attr_values(&body, "c:chart", "r:id").try_into().unwrap(); + let chart = part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), &id).1); + let rels = text(&files, "word/charts/_rels/chart1.xml.rels"); + assert_eq!(rel(&rels, "rId1").1, "chart1.xml", "its relationship to itself, to the same written part"); + assert!(!rels.contains("document.xml") && !rels.contains("gone.bin") && rels.matches("Id=\"rId4\"").count() <= 1, "{rels}"); + assert_eq!(text(&files, &chart), CHART_XML); + assert_eq!(files.keys().filter(|k| k.starts_with("word/charts/") && !k.contains("_rels")).count(), 1); +} + /// A group as Word saves one (in `mc:AlternateContent`, needing `wpg`): its members' space /// starts at `a:chOff`, and a nested group is flattened into it through its own transform. #[test] diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 48b95d4..94b2bba 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -337,6 +337,7 @@ fn images_round_trip() { // Room for a shadow: kept through save and load. float: Float { effect: [12.0, 12.0, 27.0, 27.0], ..Default::default() }, crop: [0.1, 0.0, 0.25, 0.05], + ole: None, }; let floating = InlineObject::Image { media: key.clone(), @@ -345,6 +346,7 @@ fn images_round_trip() { alt: String::new(), float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 36.0, y: 12.5, dist: 9.0, ..Default::default() }, crop: [0.0; 4], + ole: None, }; let aligned = InlineObject::Image { media: key.clone(), @@ -363,6 +365,7 @@ fn images_round_trip() { ..Default::default() }, crop: [0.0; 4], + ole: None, }; let mut floats = vec![floating.clone(), aligned]; for wrap in [Wrap::Tight, Wrap::Through, Wrap::TopAndBottom, Wrap::BehindText, Wrap::InFrontOfText] { @@ -373,6 +376,7 @@ fn images_round_trip() { alt: String::new(), float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 0.0, y: 0.0, dist: 0.0, ..Default::default() }, crop: [0.0; 4], + ole: None, }); } let mut p = Paragraph::with_text("pic: ", CharProps::default()); @@ -866,7 +870,7 @@ fn groups_round_trip() { let mut d = Document::new(); let key = d.add_media(tiny_png(), "png"); let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]); - let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4] }; + let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4], ole: None }; let oval = InlineObject::Shape { kind: ShapeKind::Ellipse, w: 50.0, @@ -1024,7 +1028,7 @@ fn kitchen_sink() -> Document { let mut img = Paragraph::new(); img.insert_object( 0, - InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4] }, + InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4], ole: None }, &CharProps::default(), ) .unwrap(); @@ -1081,7 +1085,15 @@ fn hostile_model_values_still_write() { .unwrap(); p.insert_object( 0, - InlineObject::Image { media: "missing.png".into(), w: -5.0, h: f32::NAN, alt: String::new(), float: Float::default(), crop: [f32::NAN; 4] }, + InlineObject::Image { + media: "missing.png".into(), + w: -5.0, + h: f32::NAN, + alt: String::new(), + float: Float::default(), + crop: [f32::NAN; 4], + ole: None, + }, &CharProps::default(), ) .unwrap(); @@ -1290,6 +1302,7 @@ fn rotation_and_flips_round_trip() { alt: String::new(), float: Float { rot: 30.0, effect: [6.0; 4], ..Default::default() }, crop: [0.0; 4], + ole: None, }; let shape = |kind, float| InlineObject::Shape { kind, diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 5a0dfd0..8f1ade8 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -232,8 +232,15 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult { } let props = s.typing_props(); let at = delete_selection(s)?; - let obj = - InlineObject::Image { media: key.clone(), w, h, alt: p::str(v, "alt").unwrap_or("").to_string(), float: Float::default(), crop: [0.0; 4] }; + let obj = InlineObject::Image { + media: key.clone(), + w, + h, + alt: p::str(v, "alt").unwrap_or("").to_string(), + float: Float::default(), + crop: [0.0; 4], + ole: None, + }; let end = s.doc.insert_object(&at, obj, &props)?; s.sel = Selection { anchor: at, focus: end }; Ok(json!({"media": key, "width": w, "height": h})) diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 0ebd31b..21e668a 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -384,7 +384,7 @@ impl Builder<'_> { Inline::Image(img) => { let key = self.doc.add_media(img.data.to_vec(), &img.ext); let (w, h) = (finite_or(img.w, 72.0).clamp(1.0, 1584.0), finite_or(img.h, 72.0).clamp(1.0, 1584.0)); - let obj = InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: Default::default(), crop: [0.0; 4] }; + let obj = InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: Default::default(), crop: [0.0; 4], ole: None }; append(&mut out, "", &CharProps::default(), Some(obj)); } Inline::Anchor(name) => { diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 546d97b..889d8f2 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -65,7 +65,7 @@ fn sample() -> Document { let n = ip.len(); ip.insert_object( n, - InlineObject::Image { media: key, w: 60.0, h: 30.0, alt: "tiny".into(), float: Default::default(), crop: [0.0; 4] }, + InlineObject::Image { media: key, w: 60.0, h: 30.0, alt: "tiny".into(), float: Default::default(), crop: [0.0; 4], ole: None }, &CharProps::default(), ) .unwrap(); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6f67a27..7f123c1 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2544,7 +2544,7 @@ fn oversized_graphic_draws_shrunk_with_its_items() { stroke: Some(Rgb::BLACK), stroke_width: 4.0, }]; - let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0 }; + let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0, source: None }; let obj = InlineObject::Graphic { w: 1000.0, h: 500.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }; let mut d = Document::from_text("Chart"); d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap(); diff --git a/crates/layout/src/tests_bidi.rs b/crates/layout/src/tests_bidi.rs index da738e5..3540618 100644 --- a/crates/layout/src/tests_bidi.rs +++ b/crates/layout/src/tests_bidi.rs @@ -423,8 +423,15 @@ fn inline_picture_in_rtl_text_sits_in_its_place() { let s = "متن بیشتر"; let mut d = doc_of(&[(s, true)]); let off = at(s, 4); - let pic = - wordcraft_doc::InlineObject::Image { media: "m".into(), w: 30.0, h: 20.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }; + let pic = wordcraft_doc::InlineObject::Image { + media: "m".into(), + w: 30.0, + h: 20.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(off, pic, &Default::default()).unwrap(); let l = lay(&d); let (pl, x) = first_line(&l, 0); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b6591c6..ae8d78c 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -51,7 +51,15 @@ fn sample() -> Document { let n = ip.len(); ip.insert_object( n, - InlineObject::Image { media: key, w: 80.0, h: 40.0, alt: "gradient".into(), float: Default::default(), crop: [0.1, 0.0, 0.0, 0.0] }, + InlineObject::Image { + media: key, + w: 80.0, + h: 40.0, + alt: "gradient".into(), + float: Default::default(), + crop: [0.1, 0.0, 0.0, 0.0], + ole: None, + }, &CharProps::default(), ) .unwrap(); @@ -186,13 +194,14 @@ fn hostile_documents_export() { alt: String::new(), float: Default::default(), crop: [f32::NAN; 4], + ole: None, }, &CharProps::default(), ) .unwrap(); p.insert_object( 0, - InlineObject::Image { media: "missing".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }, + InlineObject::Image { media: "missing".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }, &CharProps::default(), ) .unwrap(); @@ -645,7 +654,7 @@ fn metafile_pictures_are_drawn_as_vector_paths() { let n = p.len(); p.insert_object( n, - InlineObject::Image { media, w: 70.0, h: 70.0, alt: "red".into(), float: Default::default(), crop: [0.0; 4] }, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: "red".into(), float: Default::default(), crop: [0.0; 4], ole: None }, &CharProps::default(), ) .unwrap(); @@ -690,7 +699,7 @@ fn metafile_strokes_use_round_caps_and_joins() { let n = p.len(); p.insert_object( n, - InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }, &CharProps::default(), ) .unwrap(); @@ -721,7 +730,7 @@ fn metafile_fill_items_carry_no_stroke_state() { let n = p.len(); p.insert_object( n, - InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }, &CharProps::default(), ) .unwrap(); @@ -745,7 +754,7 @@ fn cosmetic_metafile_pens_are_thin_but_visible() { let n = p.len(); p.insert_object( n, - InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }, &CharProps::default(), ) .unwrap(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fe99e42..792672c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -932,7 +932,15 @@ mod tests { .unwrap(); let media = d.add_media(png, "png"); let mut hp = wordcraft_doc::Paragraph::new(); - let obj = wordcraft_doc::para::InlineObject::Image { media, w: 40.0, h: 40.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }; + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 40.0, + h: 40.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; hp.insert_object(0, obj, &Default::default()).unwrap(); let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); d.last_section.headers.default = Some(id); @@ -995,7 +1003,15 @@ mod tests { let mut d = Document::new(); let media = d.add_media(red_right_half_emf(), "emf"); let mut p = wordcraft_doc::Paragraph::new(); - let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }; + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); @@ -1023,7 +1039,15 @@ mod tests { let mut d = Document::new(); let media = d.add_media(emf, "emf"); let mut p = wordcraft_doc::Paragraph::new(); - let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }; + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); @@ -1049,7 +1073,15 @@ mod tests { // The header alone has no records, so the metafile does not parse. let media = d.add_media(red_right_half_emf()[..88].to_vec(), "emf"); let mut p = wordcraft_doc::Paragraph::new(); - let obj = wordcraft_doc::para::InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }; + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; p.insert_object(0, obj, &Default::default()).unwrap(); d.body = vec![wordcraft_doc::para_block(p)]; let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8765511..f1c26ab 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -19,7 +19,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | Format | Weight | Word | WordCraft read | WordCraft write | Fidelity | Hours | How it's tested | Notes | |---|---|---|---|---|---|---|---|---| -| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 28, fixtures 21, malformed 8, bidi 4, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below | +| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 28, fixtures 27, malformed 8, bidi 4, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~55% | 25–40 | `crates/docbin/src/tests.rs` (50) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures. Metafile (WMF/EMF) pictures, Word 6/95 files and encrypted files are refused or dropped. No writer | @@ -56,10 +56,12 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written, laid out as square | +| Group shapes (`wpg:`), drawing canvas | 🟡 | 🟡 | Group shapes read and written (#267); drawing canvas (`wpc:`) dropped | | DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds | -| Group shapes (`wpg:`), drawing canvas | ❌ | ❌ | Dropped | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) | -| Charts (`c:chart`), SmartArt (`dgm`), ink, 3D models, OLE objects (`w:object`) | ❌ | ❌ | Dropped on read, not preserved on save. A document with a chart loses it silently | +| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292); not editable. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319) | +| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size set in WordCraft (#319). Can't be opened or edited | +| Ink, 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | | Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) | @@ -71,8 +73,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen 1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with Word (local only: Word output can't be committed; keep a manifest and our own renders). -2. **Preserve what we can't render**: charts, SmartArt, OLE, ink and content controls survive a - round trip (keep the parts and the run), and charts/SmartArt show their fallback picture. +2. **Preserve what we can't render**: ink, group shapes and content controls survive a round trip + (keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319). 3. Formatting revisions and content controls read and written. 4. Encrypted `.docx` opens with a password. @@ -80,6 +82,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded | | 2026-10-10 | trivial | DrawingML rotation and flips read and written (#332) | | 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) | | 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) | diff --git a/docs/gaps.md b/docs/gaps.md index 2379a08..630c739 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -12,7 +12,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | # | Gap | B | Evidence | Impact | Hours | Doc | |---|---|---|---|---|---|---| | 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures; "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | -| 2 | **Charts, SmartArt, OLE objects, ink and group shapes are dropped on open and lost on save, silently** | B | No `c:chart`, `dgm`, `wpg`, `w:object` handling in `crates/docx` | Business and academic documents lose content without warning | 10–15 to preserve and show fallback pictures; 45–70 to render and edit charts and SmartArt | [file-format-parity.md](file-format-parity.md) | +| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; columns don't balance; custom footnote continuation separators and notices aren't read; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | #2 narrowed: charts and SmartArt drawn (#292), and with OLE objects kept on save (#319); group shapes landed (#267); ink and drawing canvases remain | | 2026-10-10 | trivial | #4: long footnotes continue onto the next page (#352) | | 2026-10-10 | trivial | Define New Multilevel List and Track Changes Options dialogs landed (#328); removed from the dialog-depth list | | 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright |