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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Merge fix: #340's rotation round-trip test builds an Image with #333's ole field

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-10 23:48:51 +02:00 committed by GitHub
parent 79b6f33b03
commit a7c809e63b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1152 additions and 41 deletions

View File

@ -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 |

View File

@ -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<std::sync::Arc<Embedded>>,
}
/// 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<EmbeddedRef>,
}
/// 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)]

View File

@ -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<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
Graphic {

View File

@ -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),
}

View File

@ -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<Rel>,
}
/// 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<String, EmbeddedPart>,
}
/// 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<u8> {
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<u8>)]) -> Result<Vec<u8>, 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());
}
}

View File

@ -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<String> {
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<GraphicItem> {
let Some(root) = self.graphic_part(path) else { return Vec::new() };

View File

@ -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<Arc<Embedded>> {
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<Rel> = 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<String>, 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<String> {
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)
}

View File

@ -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>>,
/// 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<ContentTypes>,
/// 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<Document, DocxError> {
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<Document, DocxError> {
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) {

View File

@ -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<String> = 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<Graphic> {
let source = match kind {
fn graphic(&mut self, kind: GraphicKind, gd: &El, rels: &Rels, w: f32, h: f32, source: Option<Arc<Embedded>>) -> Arc<Graphic> {
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<InlineObject> {
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<Table> {

View File

@ -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<String, Arc<Vec<u8>>>,
/// Part names in use (lower case): this writer's own, and parts written so far.
taken: HashSet<String>,
/// Next suffix to try when renaming a part.
next: HashMap<String, u32>,
/// Original part name → name written, for parts written by an object's first copy.
first: HashMap<String, String>,
/// 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<u8>, 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<String> = [
"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<Item = &'a String>) {
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<String, String>, written: &mut Vec<usize>) -> Option<String> {
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<String> {
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<String, String> = 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<String>, 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<String, String>, 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<String, String>) -> Vec<u8> {
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<String> = 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<String, String> = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect();
let out = patch_rel_ids(br#"<x relId="rId9"/><y relId="rId2"/><z relId="rId7"/><w relId="unterminated"#, &ids);
assert_eq!(String::from_utf8(out).unwrap(), r#"<x relId="rId2"/><y relId="rId3"/><z relId="rId7"/><w relId="unterminated"#);
}
#[test]
fn vml_style_takes_the_new_size_and_offset() {
let inline = vml_style_with("width:100pt;height:50pt;mso-wrap-style:square", 120.5, 60.0, &Float::default());
assert_eq!(inline, "mso-wrap-style:square;width:120.5pt;height:60pt");
let float = Float { x: 10.0, y: 20.25, ..Float::default() };
let abs = vml_style_with("position:absolute;left:5pt;margin-left:3pt;top:1pt;width:1pt;height:1pt", 2.0, 3.0, &float);
assert_eq!(abs, "position:absolute;margin-left:10pt;margin-top:20.25pt;width:2pt;height:3pt");
assert_eq!(pt(f32::NAN), "0");
}
}

View File

@ -1,5 +1,6 @@
//! [`Document`] → DOCX.
mod embed;
mod math;
mod props;
mod story;
@ -33,6 +34,14 @@ impl PartRels {
self.index.insert(key, id.clone());
id
}
/// Add a relationship with a given id (the id a kept part's markup uses); a repeated id is
/// ignored. Not to be mixed with [`PartRels::add`] in one part.
pub fn add_with_id(&mut self, id: &str, kind: &str, target: &str, external: bool) {
if self.list.iter().any(|(i, ..)| i == id) {
return;
}
self.list.push((id.to_string(), kind.to_string(), target.to_string(), external));
}
fn is_empty(&self) -> bool {
self.list.is_empty()
}
@ -84,6 +93,8 @@ pub(crate) struct Writer<'d> {
used_media: std::collections::BTreeSet<String>,
/// 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<Vec<u8>, 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<u8>)> = 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<Vec<u8>, 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()) {

View File

@ -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", &[]);

View File

@ -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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart><c:externalData r:id="rId1"><c:autoUpdate val="0"/></c:externalData></c:chartSpace>"#;
/// 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#"<cs:colorStyle xmlns:cs="http://schemas.microsoft.com/office/drawing/2012/chartStyle" meth="cycle" id="10"/>"#;
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<u8> {
let body = r#"<w:p><w:r><w:t>Sales</w:t></w:r><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rId7"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
let types = format!(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chart1.xml" ContentType="{CT_CHART}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
);
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<String, Vec<u8>> {
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<String, Vec<u8>>, 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<String> {
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("<Relationship ").find(|r| r.contains(&format!("Id=\"{id}\""))).unwrap_or_else(|| panic!("no {id} in {rels}"));
let get = |a: &str| r.split(&format!("{a}=\"")).nth(1).unwrap().split('"').next().unwrap().to_string();
(get("Type"), get("Target"))
}
/// Package part a relationship target of `from_dir` names.
fn part_at(from_dir: &str, target: &str) -> 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<String, Vec<u8>>, 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<InlineObject> {
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("<wp:posOffset>457200</wp:posOffset>").count(), 2, "the new position");
let ids = attr_values(&body, "c:chart", "r:id");
assert_eq!(ids.len(), 2);
let charts: Vec<String> = 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##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
let picture = "\u{89}PNG stand-in";
let ole = "\u{d0}\u{cf} opaque OLE storage";
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
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("<w:drawing>"), "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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="x/package" Target="chart1.xml"/><Relationship Id="rId2" Type="x/a" Target="../document.xml"/><Relationship Id="rId3" Type="x/b" Target="../_rels/document.xml.rels"/><Relationship Id="rId4" Type="x/c" Target="gone.bin"/><Relationship Id="rId4" Type="x/d" Target="chart1.xml"/></Relationships>"#;
let drawing = |id: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="C"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="{id}"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let body = format!("<w:p>{}{}</w:p>", 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<bool> = 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]

View File

@ -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,

View File

@ -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}))

View File

@ -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) => {

View File

@ -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();

View File

@ -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();

View File

@ -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);

View File

@ -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();

View File

@ -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());

View File

@ -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) |

View File

@ -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 |